From e7cdd19dfef1934899b0312d602737af0124cbea Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sat, 10 Aug 2019 21:47:57 +0200 Subject: [PATCH 001/103] Small changes and Snap Frame addition. Snap allows for the retrieval of a single frame from the current Camera video stream. PEP8 standard implemented. --- .idea/.gitignore | 2 + .idea/ReolinkCameraAPI.iml | 11 ++++ .idea/codeStyles/codeStyleConfig.xml | 5 ++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 7 +++ .idea/modules.xml | 8 +++ .idea/vcs.xml | 6 ++ APIHandler.py | 57 +++++++++++++++++-- 8 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/ReolinkCameraAPI.iml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..5c98b42 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,2 @@ +# Default ignored files +/workspace.xml \ No newline at end of file diff --git a/.idea/ReolinkCameraAPI.iml b/.idea/ReolinkCameraAPI.iml new file mode 100644 index 0000000..6711606 --- /dev/null +++ b/.idea/ReolinkCameraAPI.iml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..3999087 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8125795 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/APIHandler.py b/APIHandler.py index 2653081..33f5472 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -1,26 +1,46 @@ +import io import json +import random +import string +from urllib.request import urlopen + +from PIL import Image from resthandle import Request class APIHandler: + """ + The APIHandler class is the backend part of the API. + This handles communication directly with the camera. + + 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): + def __init__(self, ip: str, username: str, password: str): + """ + Initialise the Camera API Handler (maps api calls into python) + :param ip: + :param username: + :param password: + """ self.url = "http://" + ip + "/cgi-bin/api.cgi" self.token = None + self.username = username + self.password = password # Token - def login(self, username: str, password: str): + def login(self): """ Get login token Must be called first, before any other operation can be performed - :param username: - :param password: :return: """ try: - body = [{"cmd": "Login", "action": 0, "param": {"User": {"userName": username, "password": password}}}] + body = [{"cmd": "Login", "action": 0, + "param": {"User": {"userName": self.username, "password": self.password}}}] param = {"cmd": "Login", "token": "null"} response = Request.post(self.url, data=body, params=param) if response is not None: @@ -176,3 +196,30 @@ def get_general_system(self): except Exception as e: print("Could not get General System settings\n", e) raise + + ########## + # Image Data + ########## + def get_snap(self, timeout=3) -> Image or None: + """ + Gets a "snap" of the current camera video data and returns a Pillow Image or None + :param timeout: + :return: + """ + try: + randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) + snap = "?cmd=Snap&channel=0&rs=" \ + + randomstr \ + + "&user=" + self.username \ + + "&password=" + self.password + reader = urlopen(self.url + snap, timeout) + if reader.status == 200: + b = bytearray(reader.read()) + return Image.open(io.BytesIO(b)) + print("Could not retrieve data from camera successfully. Status:", reader.status) + return None + + except Exception as e: + print("Could not get Image data\n", e) + raise + From 696b3ac58f652394b3f36a92432a1b4847522e85 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sat, 10 Aug 2019 21:48:47 +0200 Subject: [PATCH 002/103] Camera class updated --- Camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Camera.py b/Camera.py index 1e82361..317840d 100644 --- a/Camera.py +++ b/Camera.py @@ -4,8 +4,8 @@ class Camera(APIHandler): def __init__(self, ip, username="admin", password=""): - APIHandler.__init__(self, ip) + APIHandler.__init__(self, ip, username, password) self.ip = ip self.username = username self.password = password - super().login(self.username, self.password) + super().login() From 4ebc0a2c972e4a4bbe6ffddfc548ce0f3207b85b Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sun, 11 Aug 2019 11:24:02 +0200 Subject: [PATCH 003/103] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a9eff6e..20aa9b5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ You can get the Restful API calls by looking through the HTTP Requests made the Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. +### Styling and Standards + +This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) + ### API Requests Implementation Plan: GET: @@ -47,7 +51,7 @@ GET: - [ ] Focus - [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) -- [ ] Image Data +- [ ] Image Data -> "Snap" Frame from Video Stream SET: - [ ] Display -> OSD From f23c5e8c29c8f485ef78513d50ac76271b8e1508 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sun, 11 Aug 2019 13:24:35 +0200 Subject: [PATCH 004/103] Added User management and osd --- APIHandler.py | 148 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 20 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index 33f5472..15551be 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -32,11 +32,11 @@ def __init__(self, ip: str, username: str, password: str): # Token - def login(self): + def login(self) -> bool: """ Get login token Must be called first, before any other operation can be performed - :return: + :return: bool """ try: body = [{"cmd": "Login", "action": 0, @@ -49,9 +49,12 @@ def login(self): if int(code) == 0: self.token = data["value"]["Token"]["name"] print("Login success") + return True print(self.token) + return False else: print("Failed to login\nStatus Code:", response.status_code) + return False except Exception as e: print("Error Login\n", e) raise @@ -63,43 +66,48 @@ def login(self): ########### # SET Network ########### - def set_net_port(self, httpPort=80, httpsPort=443, mediaPort=9000, onvifPort=8000, rtmpPort=1935, rtspPort=554): + def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port=8000, rtmp_port=1935, + rtsp_port=554) -> bool: """ Set network ports If nothing is specified, the default values will be used - :param httpPort: - :param httpsPort: - :param mediaPort: - :param onvifPort: - :param rtmpPort: - :param rtspPort: - :return: + :param rtsp_port: int + :param rtmp_port: int + :param onvif_port: int + :param media_port: int + :param https_port: int + :type http_port: int + :return: bool """ try: if self.token is None: raise ValueError("Login first") body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": { - "httpPort": httpPort, - "httpsPort": httpsPort, - "mediaPort": mediaPort, - "onvifPort": onvifPort, - "rtmpPort": rtmpPort, - "rtspPort": rtspPort + "httpPort": http_port, + "httpsPort": https_port, + "mediaPort": media_port, + "onvifPort": onvif_port, + "rtmpPort": rtmp_port, + "rtspPort": rtsp_port }}}] param = {"token": self.token} response = Request.post(self.url, data=body, params=param) if response is not None: if response.status_code == 200: print("Successfully Set Network Ports") + return True else: print("Something went wront\nStatus Code:", response.status_code) + return False + + return False except Exception as e: print("Setting Network Port Error\n", e) raise - def set_wifi(self, ssid, password): + def set_wifi(self, ssid, password) -> json or None: try: if self.token is None: raise ValueError("Login first") @@ -185,18 +193,119 @@ def scan_wifi(self): ########### # GET ########### - def get_general_system(self): + def get_general_system(self) -> json or None: try: if self.token is None: raise ValueError("Login first") body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}] param = {"token": self.token} response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve general information from camera successfully. Status:", response.status_code) + return None except Exception as e: print("Could not get General System settings\n", e) raise + def get_osd(self) -> json or None: + try: + param = {"cmd": "GetOsd", "token": self.token} + body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve OSD from camera successfully. Status:", response.status_code) + return None + except Exception as e: + print("Could not get OSD", e) + raise + + ########## + # User + ########## + + ########## + # GET + ########## + def get_online_user(self) -> json or None: + try: + param = {"cmd": "GetOnline", "token": self.token} + body = [{"cmd": "GetOnline", "action": 1, "param": {}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve online user from camera. Status:", response.status_code) + return None + except Exception as e: + print("Could not get online user", e) + raise + + def get_users(self) -> json or None: + try: + param = {"cmd": "GetUser", "token": self.token} + body = [{"cmd": "GetUser", "action": 1, "param": {}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve users from camera. Status:", response.status_code) + return None + except Exception as e: + print("Could not get users", e) + raise + + ########## + # SET + ########## + def add_user(self, username: str, password: str, level: str = "guest") -> bool: + try: + param = {"cmd": "AddUser", "token": self.token} + body = [{"cmd": "AddUser", "action": 0, + "param": {"User": {"userName": username, "password": password, "level": level}}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + r_data = json.loads(response.text) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not add user. Camera responded with:", r_data["value"]) + return False + print("Something went wrong. Could not add user. Status:", response.status_code) + return False + except Exception as e: + print("Could not add user", e) + raise + + def modify_user(self, username: str, password: str) -> bool: + try: + param = {"cmd": "ModifyUser", "token": self.token} + body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + r_data = json.loads(response.text) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not modify user:", username, "\nCamera responded with:", r_data["value"]) + print("Something went wrong. Could not modify user. Status:", response.status_code) + return False + except Exception as e: + print("Could not modify user", e) + raise + + def delete_user(self, username: str) -> bool: + try: + param = {"cmd": "DelUser", "token": self.token} + body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + r_data = json.loads(response.text) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not delete user:", username, "\nCamera responded with:", r_data["value"]) + return False + except Exception as e: + print("Could not delete user", e) + raise + ########## # Image Data ########## @@ -222,4 +331,3 @@ def get_snap(self, timeout=3) -> Image or None: except Exception as e: print("Could not get Image data\n", e) raise - From 2722a80922bad912f645cfbb0ab9a7cac1c91353 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sun, 11 Aug 2019 13:45:51 +0200 Subject: [PATCH 005/103] Added some doc strings to User management and HDD api --- APIHandler.py | 88 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index 15551be..75c51e5 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -229,6 +229,10 @@ def get_osd(self) -> json or None: # GET ########## def get_online_user(self) -> json or None: + """ + Return a list of current logged-in users in json format + :return: json or None + """ try: param = {"cmd": "GetOnline", "token": self.token} body = [{"cmd": "GetOnline", "action": 1, "param": {}}] @@ -242,6 +246,10 @@ def get_online_user(self) -> json or None: raise def get_users(self) -> json or None: + """ + Return a list of user accounts from the camera in json format + :return: json or None + """ try: param = {"cmd": "GetUser", "token": self.token} body = [{"cmd": "GetUser", "action": 1, "param": {}}] @@ -258,6 +266,13 @@ def get_users(self) -> json or None: # SET ########## def add_user(self, username: str, password: str, level: str = "guest") -> bool: + """ + Add a new user account to the camera + :param username: The user's username + :param password: The user's password + :param level: The privilege level 'guest' or 'admin'. Default is 'guest' + :return: bool + """ try: param = {"cmd": "AddUser", "token": self.token} body = [{"cmd": "AddUser", "action": 0, @@ -276,6 +291,12 @@ def add_user(self, username: str, password: str, level: str = "guest") -> bool: raise def modify_user(self, username: str, password: str) -> bool: + """ + Modify the user's password by specifying their username + :param username: The user which would want to be modified + :param password: The new password + :return: bool + """ try: param = {"cmd": "ModifyUser", "token": self.token} body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] @@ -292,6 +313,11 @@ def modify_user(self, username: str, password: str) -> bool: raise def delete_user(self, username: str) -> bool: + """ + Delete a user by specifying their username + :param username: The user which would want to be deleted + :return: bool + """ try: param = {"cmd": "DelUser", "token": self.token} body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] @@ -309,11 +335,11 @@ def delete_user(self, username: str) -> bool: ########## # Image Data ########## - def get_snap(self, timeout=3) -> Image or None: + def get_snap(self, timeout: int = 3) -> Image or None: """ Gets a "snap" of the current camera video data and returns a Pillow Image or None - :param timeout: - :return: + :param timeout: Request timeout to camera in seconds + :return: Image or None """ try: randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) @@ -331,3 +357,59 @@ def get_snap(self, timeout=3) -> Image or None: except Exception as e: print("Could not get Image data\n", e) raise + + ######### + # Device + ######### + def get_hdd_info(self) -> json or None: + """ + Gets all HDD and SD card information from Camera + Format is as follows: + [{"cmd" : "GetHddInfo", + "code" : 0, + "value" : { + "HddInfo" : [{ + "capacity" : 15181, + "format" : 1, + "id" : 0, + "mount" : 1, + "size" : 15181 + }] + } + }] + + :return: json or None + """ + try: + param = {"cmd": "GetHddInfo", "token": self.token} + body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve HDD/SD info from camera successfully. Status:", response.status_code) + return None + except Exception as e: + print("Could not get HDD/SD card information", e) + raise + + def format_hdd(self, hdd_id: [int] = [0]) -> 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 + """ + try: + param = {"cmd": "Format", "token": self.token} + body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + r_data = json.loads(response.text) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not format HDD/SD. Camera responded with:", r_data["value"]) + return False + print("Could not format HDD/SD. Status:", response.status_code) + return False + except Exception as e: + print("Could not format HDD/SD", e) + raise From 5bc90f7c607ca24a2840ce6abfd84c8d573d2ddf Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sun, 11 Aug 2019 15:35:52 +0200 Subject: [PATCH 006/103] Added some API's and Doc strings OSD -> set and get System -> get_performance, get_information Recording -> get --- APIHandler.py | 469 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 458 insertions(+), 11 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index 75c51e5..bd35b67 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -13,6 +13,7 @@ class APIHandler: """ The APIHandler class is the backend part of the API. This handles communication directly with the camera. + Current camera's tested: RLC-411WS All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/ @@ -30,7 +31,9 @@ def __init__(self, ip: str, username: str, password: str): self.username = username self.password = password + ########### # Token + ########### def login(self) -> bool: """ @@ -100,9 +103,7 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port else: print("Something went wront\nStatus Code:", response.status_code) return False - return False - except Exception as e: print("Setting Network Port Error\n", e) raise @@ -126,7 +127,7 @@ def set_wifi(self, ssid, password) -> json or None: ########### # GET ########### - def get_net_ports(self): + def get_net_ports(self) -> json or None: """ Get network ports :return: @@ -140,7 +141,10 @@ def get_net_ports(self): {"cmd": "GetP2p", "action": 0, "param": {}}] param = {"token": self.token} response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) + if response.status_code == 200: + return json.loads(response.text) + print("Could not get network ports data. Status:", response.status_code) + return None except Exception as e: print("Get Network Ports", e) @@ -157,7 +161,9 @@ def get_link_local(self): body = [{"cmd": "GetLocalLink", "action": 1, "param": {}}] param = {"cmd": "GetLocalLink", "token": self.token} request = Request.post(self.url, data=body, params=param) - return json.loads(request.text) + if request.status_code == 200: + return json.loads(request.text) + print("Could not get ") except Exception as e: print("Could not get Link Local", e) raise @@ -186,6 +192,113 @@ def scan_wifi(self): print("Could not Scan wifi\n", e) raise + ########### + # Display + ########### + + ########### + # GET + ########### + def get_osd(self) -> json or None: + """ + Get OSD information. + Response data format is as follows: + [{"cmd" : "GetOsd","code" : 0, "initial" : { + "Osd" : {"bgcolor" : 0,"channel" : 0,"osdChannel" : {"enable" : 1,"name" : "Camera1","pos" : "Lower Right"}, + "osdTime" : {"enable" : 1,"pos" : "Top Center"} + }},"range" : {"Osd" : {"bgcolor" : "boolean","channel" : 0,"osdChannel" : {"enable" : "boolean","name" : {"maxLen" : 31}, + "pos" : ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] + }, + "osdTime" : {"enable" : "boolean","pos" : ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] + } + }},"value" : {"Osd" : {"bgcolor" : 0,"channel" : 0,"osdChannel" : {"enable" : 0,"name" : "FarRight","pos" : "Lower Right"}, + "osdTime" : {"enable" : 0,"pos" : "Top Center"} + }}}] + :return: json or None + """ + try: + param = {"cmd": "GetOsd", "token": self.token} + body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve OSD from camera successfully. Status:", response.status_code) + return None + except Exception as e: + print("Could not get OSD", e) + raise + + def get_mask(self) -> json or None: + """ + Get the camera mask information + Response data format is as follows: + [{"cmd" : "GetMask","code" : 0,"initial" : { + "Mask" : { + "area" : [{"block" : {"height" : 0,"width" : 0,"x" : 0,"y" : 0},"screen" : {"height" : 0,"width" : 0}}], + "channel" : 0, + "enable" : 0 + } + },"range" : {"Mask" : {"channel" : 0,"enable" : "boolean","maxAreas" : 4}},"value" : { + "Mask" : { + "area" : null, + "channel" : 0, + "enable" : 0} + } + }] + :return: json or None + """ + try: + param = {"cmd": "GetMask", "token": self.token} + body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not get Mask from camera successfully. Status:", response.status_code) + return None + except Exception as e: + print("Could not get mask", e) + raise + + ########### + # SET + ########### + 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, + osd_time_pos: str = "Lower Right") -> bool: + """ + Set OSD + :param bg_color: bool + :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_time_enabled: bool + :param osd_time_pos: string time position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] + :return: + """ + try: + param = {"cmd": "setOsd", "token": self.token} + 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} + } + } + }] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + r_data = json.loads(response.text) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not set OSD. Camera responded with status:", r_data["value"]) + return False + print("Could not set OSD. Status:", response.status_code) + return False + except Exception as e: + print("Could not set OSD", e) + raise + ########### # SYSTEM ########### @@ -208,17 +321,71 @@ def get_general_system(self) -> json or None: print("Could not get General System settings\n", e) raise - def get_osd(self) -> json or None: + def get_performance(self) -> json or None: + """ + Get a snapshot of the current performance of the camera. + Response data format is as follows: + [{"cmd" : "GetPerformance", + "code" : 0, + "value" : { + "Performance" : { + "codecRate" : 2154, + "cpuUsed" : 14, + "netThroughput" : 0 + } + } + }] + :return: json or None + """ try: - param = {"cmd": "GetOsd", "token": self.token} - body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] + param = {"cmd": "GetPerformance", "token": self.token} + body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] response = Request.post(self.url, data=body, params=param) if response.status_code == 200: return json.loads(response.text) - print("Could not retrieve OSD from camera successfully. Status:", response.status_code) + print("Cound not retrieve performance information from camera successfully. Status:", response.status_code) return None except Exception as e: - print("Could not get OSD", e) + print("Could not get performance", e) + raise + + def get_information(self) -> json or None: + """ + Get the camera information + Response data format is as follows: + [{"cmd" : "GetDevInfo","code" : 0,"value" : { + "DevInfo" : { + "B485" : 0, + "IOInputNum" : 0, + "IOOutputNum" : 0, + "audioNum" : 0, + "buildDay" : "build 18081408", + "cfgVer" : "v2.0.0.0", + "channelNum" : 1, + "detail" : "IPC_3816M100000000100000", + "diskNum" : 1, + "firmVer" : "v2.0.0.1389_18081408", + "hardVer" : "IPC_3816M", + "model" : "RLC-411WS", + "name" : "Camera1_withpersonality", + "serial" : "00000000000000", + "type" : "IPC", + "wifi" : 1 + } + } + }] + :return: json or None + """ + try: + param = {"cmd": "GetDevInfo", "token": self.token} + body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] + response = Request.post(self.url, data=body, params=param) + if response == 200: + return json.loads(response.text) + print("Could not retrieve camera information. Status:", response.status_code) + return None + except Exception as e: + print("Could not get device information", e) raise ########## @@ -231,6 +398,17 @@ def get_osd(self) -> json or None: def get_online_user(self) -> json or None: """ Return a list of current logged-in users in json format + Response data format is as follows: + [{"cmd" : "GetOnline","code" : 0,"value" : { + "User" : [{ + "canbeDisconn" : 0, + "ip" : "192.168.1.100", + "level" : "admin", + "sessionId" : 1000, + "userName" : "admin" + }] + } + }] :return: json or None """ try: @@ -248,6 +426,29 @@ def get_online_user(self) -> json or None: def get_users(self) -> json or None: """ Return a list of user accounts from the camera in json format + Response data format is as follows: + [{"cmd" : "GetUser","code" : 0,"initial" : { + "User" : { + "level" : "guest" + }}, + "range" : {"User" : { + "level" : [ "guest", "admin" ], + "password" : { + "maxLen" : 31, + "minLen" : 6 + }, + "userName" : { + "maxLen" : 31, + "minLen" : 1 + }} + },"value" : { + "User" : [ + { + "level" : "admin", + "userName" : "admin" + }] + } + }] :return: json or None """ try: @@ -364,7 +565,7 @@ def get_snap(self, timeout: int = 3) -> Image or None: def get_hdd_info(self) -> json or None: """ Gets all HDD and SD card information from Camera - Format is as follows: + Response data format is as follows: [{"cmd" : "GetHddInfo", "code" : 0, "value" : { @@ -413,3 +614,249 @@ def format_hdd(self, hdd_id: [int] = [0]) -> bool: except Exception as e: print("Could not format HDD/SD", e) raise + + ########### + # Recording + ########### + + ########### + # SET + ########### + + ########### + # GET + ########### + def get_recording_encoding(self) -> json or None: + """ + Get the current camera encoding settings for "Clear" and "Fluent" profiles. + Response data format is as follows: + [{ + "cmd" : "GetEnc", + "code" : 0, + "initial" : { + "Enc" : { + "audio" : 0, + "channel" : 0, + "mainStream" : { + "bitRate" : 4096, + "frameRate" : 15, + "profile" : "High", + "size" : "3072*1728" + }, + "subStream" : { + "bitRate" : 160, + "frameRate" : 7, + "profile" : "High", + "size" : "640*360" + } + } + }, + "range" : { + "Enc" : [ + { + "audio" : "boolean", + "mainStream" : { + "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], + "default" : { + "bitRate" : 4096, + "frameRate" : 15 + }, + "frameRate" : [ 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "3072*1728" + }, + "subStream" : { + "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], + "default" : { + "bitRate" : 160, + "frameRate" : 7 + }, + "frameRate" : [ 15, 10, 7, 4 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "640*360" + } + }, + { + "audio" : "boolean", + "mainStream" : { + "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], + "default" : { + "bitRate" : 4096, + "frameRate" : 15 + }, + "frameRate" : [ 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "2592*1944" + }, + "subStream" : { + "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], + "default" : { + "bitRate" : 160, + "frameRate" : 7 + }, + "frameRate" : [ 15, 10, 7, 4 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "640*360" + } + }, + { + "audio" : "boolean", + "mainStream" : { + "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], + "default" : { + "bitRate" : 3072, + "frameRate" : 15 + }, + "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "2560*1440" + }, + "subStream" : { + "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], + "default" : { + "bitRate" : 160, + "frameRate" : 7 + }, + "frameRate" : [ 15, 10, 7, 4 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "640*360" + } + }, + { + "audio" : "boolean", + "mainStream" : { + "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], + "default" : { + "bitRate" : 3072, + "frameRate" : 15 + }, + "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "2048*1536" + }, + "subStream" : { + "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], + "default" : { + "bitRate" : 160, + "frameRate" : 7 + }, + "frameRate" : [ 15, 10, 7, 4 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "640*360" + } + }, + { + "audio" : "boolean", + "mainStream" : { + "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], + "default" : { + "bitRate" : 3072, + "frameRate" : 15 + }, + "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "2304*1296" + }, + "subStream" : { + "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], + "default" : { + "bitRate" : 160, + "frameRate" : 7 + }, + "frameRate" : [ 15, 10, 7, 4 ], + "profile" : [ "Base", "Main", "High" ], + "size" : "640*360" + } + } + ] + }, + "value" : { + "Enc" : { + "audio" : 0, + "channel" : 0, + "mainStream" : { + "bitRate" : 2048, + "frameRate" : 20, + "profile" : "Main", + "size" : "3072*1728" + }, + "subStream" : { + "bitRate" : 64, + "frameRate" : 4, + "profile" : "High", + "size" : "640*360" + } + } + } + }] + + :return: json or None + """ + try: + param = {"cmd": "GetEnc", "token": self.token} + body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve recording encoding data. Status:", response.status_code) + return None + except Exception as e: + print("Could not get recording encoding", e) + raise + + def get_recording_advanced(self) -> json or None: + """ + Get recording advanced setup data + Response data format is as follows: + [{ + "cmd" : "GetRec", + "code" : 0, + "initial" : { + "Rec" : { + "channel" : 0, + "overwrite" : 1, + "postRec" : "15 Seconds", + "preRec" : 1, + "schedule" : { + "enable" : 1, + "table" : "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + } + } + }, + "range" : { + "Rec" : { + "channel" : 0, + "overwrite" : "boolean", + "postRec" : [ "15 Seconds", "30 Seconds", "1 Minute" ], + "preRec" : "boolean", + "schedule" : { + "enable" : "boolean" + } + } + }, + "value" : { + "Rec" : { + "channel" : 0, + "overwrite" : 1, + "postRec" : "15 Seconds", + "preRec" : 1, + "schedule" : { + "enable" : 1, + "table" : "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + } + }] + + :return: json or None + """ + try: + param = {"cmd": "GetRec", "token": self.token} + body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return json.loads(response.text) + print("Could not retrieve advanced recording. Status:", response.status_code) + return None + except Exception as e: + print("Could not get advanced recoding", e) From 475c72b241ea5160879b5b468b1538010988e746 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sun, 11 Aug 2019 21:06:21 +0200 Subject: [PATCH 007/103] Added proxy support. Bug fixes in APIHandler. RTSP support (adding) Proxy support allows contacting the camera behind a proxy (GET and POST requests). Adding RTSP support - still in progress. --- APIHandler.py | 45 ++++++++++++++++++++++++--- Camera.py | 14 ++++++++- RtspClient.py | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++ resthandle.py | 16 +++++++--- test.py | 6 ++-- 5 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 RtspClient.py diff --git a/APIHandler.py b/APIHandler.py index bd35b67..c52b624 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -2,10 +2,14 @@ import json import random import string -from urllib.request import urlopen +import sys +from urllib import request +import numpy +import rtsp from PIL import Image +from RtspClient import RtspClient from resthandle import Request @@ -19,17 +23,23 @@ class APIHandler: """ - def __init__(self, ip: str, username: str, password: str): + def __init__(self, ip: str, username: str, password: str, **kwargs): """ Initialise the Camera API Handler (maps api calls into python) :param ip: :param username: :param password: + :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 + """ + self.ip = ip self.url = "http://" + ip + "/cgi-bin/api.cgi" self.token = None self.username = username self.password = password + Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found ########### # Token @@ -380,7 +390,7 @@ def get_information(self) -> json or None: param = {"cmd": "GetDevInfo", "token": self.token} body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] response = Request.post(self.url, data=body, params=param) - if response == 200: + if response.status_code == 200: return json.loads(response.text) print("Could not retrieve camera information. Status:", response.status_code) return None @@ -543,12 +553,15 @@ def get_snap(self, timeout: int = 3) -> Image or None: :return: Image or None """ try: + randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) - snap = "?cmd=Snap&channel=0&rs=" \ + snap = self.url + "?cmd=Snap&channel=0&rs=" \ + randomstr \ + "&user=" + self.username \ + "&password=" + self.password - reader = urlopen(self.url + snap, timeout) + req = request.Request(snap) + req.set_proxy(Request.proxies, 'http') + reader = request.urlopen(req, timeout) if reader.status == 200: b = bytearray(reader.read()) return Image.open(io.BytesIO(b)) @@ -860,3 +873,25 @@ def get_recording_advanced(self) -> json or None: return None except Exception as e: print("Could not get advanced recoding", e) + + ########### + # RTSP Stream + ########### + def open_video_stream(self, profile: str = "main") -> Image: + """ + profile is "main" or "sub" + https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player + :param profile: + :return: + """ + with RtspClient(ip=self.ip, username=self.username, password=self.password, + proxies={"host": "127.0.0.1", "port": 8000}) as rtsp_client: + rtsp_client.preview() + # with rtsp.Client( + # rtsp_server_uri="rtsp://" + # + self.username + ":" + # + self.password + "@" + # + self.ip + # + ":554//h264Preview_01_" + # + profile) as client: + # return client diff --git a/Camera.py b/Camera.py index 317840d..4a4ee79 100644 --- a/Camera.py +++ b/Camera.py @@ -4,7 +4,19 @@ class Camera(APIHandler): def __init__(self, ip, username="admin", password=""): - APIHandler.__init__(self, ip, username, password) + """ + 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 + APIHandler.__init__(self, ip, username, password, proxy={"http": "socks5://127.0.0.1:8000"}) + + # Normal call without proxy: + # APIHandler.__init__(self, ip, username, password) + self.ip = ip self.username = username self.password = password diff --git a/RtspClient.py b/RtspClient.py new file mode 100644 index 0000000..b3cb3fb --- /dev/null +++ b/RtspClient.py @@ -0,0 +1,85 @@ +import socket + +import cv2 +import numpy +import socks + + +class RtspClient: + + def __init__(self, ip, username, password, port=554, profile="main", **kwargs): + """ + + :param ip: + :param username: + :param password: + :param port: rtsp port + :param profile: "main" or "sub" + :param proxies: {"host": "localhost", "port": 8000} + """ + self.ip = ip + self.username = username + self.password = password + self.port = port + self.sockt = None + self.url = "rtsp://" + self.username + ":" + self.password + "@" + self.ip + ":" + str( + self.port) + "//h264Preview_01_" + profile + self.proxy = kwargs.get("proxies") + + def __enter__(self): + self.sockt = self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.sockt.close() + + def connect(self) -> socket: + try: + sockt = socks.socksocket(socket.AF_INET, socket.SOCK_STREAM) + sockt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if self.proxy is not None: + sockt.set_proxy(socks.SOCKS5, self.proxy["host"], self.proxy["port"]) + sockt.connect((self.ip, self.port)) + return sockt + except Exception as e: + print(e) + + def get_frame(self) -> bytearray: + try: + self.sockt.send(str.encode(self.url)) + data = b'' + while True: + try: + r = self.sockt.recv(90456) + if len(r) == 0: + break + a = r.find(b'END!') + if a != -1: + data += r[:a] + break + data += r + except Exception as e: + print(e) + continue + nparr = numpy.fromstring(data, numpy.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + return frame + except Exception as e: + print(e) + + def preview(self): + """ Blocking function. Opens OpenCV window to display stream. """ + self.connect() + win_name = 'RTSP' + cv2.namedWindow(win_name, cv2.WINDOW_AUTOSIZE) + cv2.moveWindow(win_name, 20, 20) + + while True: + cv2.imshow(win_name, self.get_frame()) + # if self._latest is not None: + # cv2.imshow(win_name,self._latest) + if cv2.waitKey(25) & 0xFF == ord('q'): + break + cv2.waitKey() + cv2.destroyAllWindows() + cv2.waitKey() diff --git a/resthandle.py b/resthandle.py index e632686..f68f406 100644 --- a/resthandle.py +++ b/resthandle.py @@ -1,9 +1,13 @@ import json import requests +import socket + +import socks class Request: + proxies = None @staticmethod def post(url: str, data, params=None) -> requests.Response or None: @@ -16,10 +20,11 @@ def post(url: str, data, params=None) -> requests.Response or None: """ try: headers = {'content-type': 'application/json'} - if params is not None: - r = requests.post(url, params=params, json=data, headers=headers) - else: - r = requests.post(url, json=data) + r = requests.post(url, 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) if r.status_code == 200: return r else: @@ -38,7 +43,8 @@ def get(url, params, timeout=1) -> json or None: :return: """ try: - data = requests.get(url=url, params=params, timeout=timeout) + data = requests.get(url=url, params=params, timeout=timeout, proxies=Request.proxies) + return data except Exception as e: print("Get Error\n", e) diff --git a/test.py b/test.py index 158ad3f..796f5a2 100644 --- a/test.py +++ b/test.py @@ -1,5 +1,5 @@ from Camera import Camera -c = Camera("192.168.1.100", "admin", "jUa2kUzi") -c.get_wifi() -c.scan_wifi() +c = Camera("192.168.1.112", "admin", "jUa2kUzi") +# print("Getting information", c.get_information()) +c.open_video_stream() From 1f5944801cad877ff2fdd0b1944678ab170f760d Mon Sep 17 00:00:00 2001 From: David Beitey Date: Sun, 15 Sep 2019 11:11:21 +1000 Subject: [PATCH 008/103] Allow API connection over HTTPS --- APIHandler.py | 5 +++-- Camera.py | 5 +++-- resthandle.py | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index 2653081..0522f5c 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -5,8 +5,9 @@ class APIHandler: - def __init__(self, ip): - self.url = "http://" + ip + "/cgi-bin/api.cgi" + def __init__(self, ip, https=False): + scheme = 'https' if https else 'http' + self.url = f"{scheme}://{ip}/cgi-bin/api.cgi" self.token = None # Token diff --git a/Camera.py b/Camera.py index 1e82361..63dca91 100644 --- a/Camera.py +++ b/Camera.py @@ -3,9 +3,10 @@ class Camera(APIHandler): - def __init__(self, ip, username="admin", password=""): - APIHandler.__init__(self, ip) + def __init__(self, ip, username="admin", password="", https=False): + APIHandler.__init__(self, ip, https=https) self.ip = ip self.username = username self.password = password + self.https = https super().login(self.username, self.password) diff --git a/resthandle.py b/resthandle.py index e632686..5b1187a 100644 --- a/resthandle.py +++ b/resthandle.py @@ -17,9 +17,9 @@ def post(url: str, data, params=None) -> requests.Response or None: try: headers = {'content-type': 'application/json'} if params is not None: - r = requests.post(url, params=params, json=data, headers=headers) + r = requests.post(url, verify=False, params=params, json=data, headers=headers) else: - r = requests.post(url, json=data) + r = requests.post(url, verify=False, json=data) if r.status_code == 200: return r else: @@ -38,7 +38,7 @@ def get(url, params, timeout=1) -> json or None: :return: """ try: - data = requests.get(url=url, params=params, timeout=timeout) + data = requests.get(url=url, verify=False, params=params, timeout=timeout) return data except Exception as e: print("Get Error\n", e) From c4600134b5ff44624c63a7337b7ed3d24e3309eb Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sun, 15 Sep 2019 22:29:42 +0200 Subject: [PATCH 009/103] Updating dev-1.0. --- APIHandler.py | 5 +++-- Camera.py | 6 +++--- resthandle.py | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index c52b624..e03379a 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -23,7 +23,7 @@ class APIHandler: """ - def __init__(self, ip: str, username: str, password: str, **kwargs): + def __init__(self, ip: str, username: str, password: str, https = False, **kwargs): """ Initialise the Camera API Handler (maps api calls into python) :param ip: @@ -34,8 +34,9 @@ def __init__(self, ip: str, username: str, password: str, **kwargs): More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679 """ + scheme = 'https' if https else 'http' + self.url = f"{scheme}://{ip}/cgi-bin/api.cgi" self.ip = ip - self.url = "http://" + ip + "/cgi-bin/api.cgi" self.token = None self.username = username self.password = password diff --git a/Camera.py b/Camera.py index 4a4ee79..1e8a52b 100644 --- a/Camera.py +++ b/Camera.py @@ -3,7 +3,7 @@ class Camera(APIHandler): - def __init__(self, ip, username="admin", password=""): + 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 @@ -12,11 +12,11 @@ def __init__(self, ip, username="admin", password=""): :param password: """ # For when you need to connect to a camera behind a proxy - APIHandler.__init__(self, ip, username, password, proxy={"http": "socks5://127.0.0.1:8000"}) + APIHandler.__init__(self, ip, username, password, proxy={"http": "socks5://127.0.0.1:8000"}, https=https) # Normal call without proxy: # APIHandler.__init__(self, ip, username, password) - + self.ip = ip self.username = username self.password = password diff --git a/resthandle.py b/resthandle.py index f68f406..6921c20 100644 --- a/resthandle.py +++ b/resthandle.py @@ -20,7 +20,7 @@ def post(url: str, data, params=None) -> requests.Response or None: """ try: headers = {'content-type': 'application/json'} - r = requests.post(url, params=params, json=data, headers=headers, proxies=Request.proxies) + 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: @@ -43,7 +43,7 @@ def get(url, params, timeout=1) -> json or None: :return: """ try: - data = requests.get(url=url, params=params, timeout=timeout, proxies=Request.proxies) + data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) return data except Exception as e: From 577dcadc685b84469dc572df00d0014ea3755efc Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Mon, 7 Oct 2019 12:21:59 -0500 Subject: [PATCH 010/103] Add System->Reboot --- APIHandler.py | 20 ++++++++++++++++++++ README.md | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index ee7472b..b855c43 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -398,6 +398,26 @@ def get_information(self) -> json or None: print("Could not get device information", e) raise + ########### + # SET + ########### + def reboot_camera(self) -> bool: + """ + Reboots the camera + :return: bool + """ + try: + param = {"cmd": "Reboot", "token": self.token} + body = [{"cmd": "Reboot", "action": 0, "param": {}}] + response = Request.post(self.url, data=body, params=param) + if response.status_code == 200: + return True + print("Something went wrong. Could not reboot camera. Status:", response.status_code) + return False + except Exception as e: + print("Could not reboot camera", e) + raise + ########## # User ########## diff --git a/README.md b/README.md index 20aa9b5..7feee35 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ This repository's purpose is to deliver a complete API for the Reolink Camera's, ### But Reolink gives an API in their documentation -Not really. They only deliver a really basic API to retrieve Image data and Video data. +Not really. They only deliver a really basic API to retrieve Image data and Video data. ### How? -You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network. +You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network. ### Get started @@ -68,7 +68,7 @@ SET: - [ ] Alarm -> Motion - [X] System -> General - [ ] System -> DST -- [ ] System -> Reboot +- [X] System -> Reboot - [ ] User -> Online User - [ ] User -> Add User - [ ] User -> Manage User From 7c0e9d164d8c2c1eaf1d016f2bb39ba19c3c561d Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Tue, 3 Mar 2020 15:01:24 +0100 Subject: [PATCH 011/103] Use pipenv for dependency management --- Pipfile | 13 +++++++ Pipfile.lock | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 Pipfile create mode 100644 Pipfile.lock diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..598a67f --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +pillow = "*" +pyyaml = "*" +requests = "*" + +[requires] diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..8d347f1 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,102 @@ +{ + "_meta": { + "hash": { + "sha256": "0016c39167fc595718fc98862c09aecb3938149a1ff375707052007de7c2ad6f" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + ], + "version": "==2019.11.28" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "pillow": { + "hashes": [ + "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", + "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", + "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", + "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", + "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", + "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", + "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", + "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", + "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", + "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", + "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", + "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", + "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", + "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", + "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", + "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", + "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", + "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", + "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", + "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", + "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", + "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" + ], + "index": "pypi", + "version": "==7.0.0" + }, + "pyyaml": { + "hashes": [ + "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", + "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", + "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", + "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", + "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", + "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", + "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", + "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", + "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", + "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", + "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + ], + "index": "pypi", + "version": "==5.3" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "index": "pypi", + "version": "==2.23.0" + }, + "urllib3": { + "hashes": [ + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + ], + "version": "==1.25.8" + } + }, + "develop": {} +} From f34c6a1a1e04f4fa6b6cd525e014836c3d663962 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Tue, 3 Mar 2020 15:02:05 +0100 Subject: [PATCH 012/103] Remove unused imports --- APIHandler.py | 3 --- resthandle.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index b855c43..c16ef6b 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -2,11 +2,8 @@ import json import random import string -import sys from urllib import request -import numpy -import rtsp from PIL import Image from RtspClient import RtspClient diff --git a/resthandle.py b/resthandle.py index a5433b1..c60d534 100644 --- a/resthandle.py +++ b/resthandle.py @@ -1,9 +1,6 @@ import json import requests -import socket - -import socks class Request: From 5d4a38f9a228109ce526f28e565d21001339cd81 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Tue, 3 Mar 2020 15:54:08 +0100 Subject: [PATCH 013/103] Refactor simple commands --- APIHandler.py | 330 +++++++++++++------------------------------------- 1 file changed, 85 insertions(+), 245 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index b855c43..66e3ed8 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -71,6 +71,22 @@ def login(self) -> bool: except Exception as e: print("Error Login\n", e) raise + + def _execute_command(self, command, data): + """ + Send a POST request to the IP camera with given JSON body and + :param command: name of the command to send + :param data: object to send to the camera + :return: response as python object + """ + try: + if self.token is None: + raise ValueError("Login first") + response = Request.post(self.url, data=data, params={"cmd": command, "token": self.token}) + return json.loads(response.text) + except Exception as e: + print(f"Command {command} failed: {e}") + raise ########### # NETWORK @@ -119,20 +135,12 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port raise def set_wifi(self, ssid, password) -> json or None: - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "SetWifi", "action": 0, "param": { - "Wifi": { - "ssid": ssid, - "password": password - }}}] - param = {"cmd": "SetWifi", "token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Could not Set Wifi details", e) - raise + body = [{"cmd": "SetWifi", "action": 0, "param": { + "Wifi": { + "ssid": ssid, + "password": password + }}}] + return self._execute_command('SetWifi', body) ########### # GET @@ -158,49 +166,13 @@ def get_net_ports(self) -> json or None: except Exception as e: print("Get Network Ports", e) - def get_link_local(self): - """ - Get General network data - This includes IP address, Device mac, Gateway and DNS - :return: - """ - try: - if self.token is None: - raise ValueError("Login first") - - body = [{"cmd": "GetLocalLink", "action": 1, "param": {}}] - param = {"cmd": "GetLocalLink", "token": self.token} - request = Request.post(self.url, data=body, params=param) - if request.status_code == 200: - return json.loads(request.text) - print("Could not get ") - except Exception as e: - print("Could not get Link Local", e) - raise - def get_wifi(self): - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "GetWifi", "action": 1, "param": {}}] - param = {"cmd": "GetWifi", "token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Could not get Wifi\n", e) - raise + body = [{"cmd": "GetWifi", "action": 1, "param": {}}] + return self._execute_command('GetWifi', body) def scan_wifi(self): - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] - param = {"cmd": "ScanWifi", "token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Could not Scan wifi\n", e) - raise + body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] + return self._execute_command('ScanWifi', body) ########### # Display @@ -226,17 +198,8 @@ def get_osd(self) -> json or None: }}}] :return: json or None """ - try: - param = {"cmd": "GetOsd", "token": self.token} - body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not retrieve OSD from camera successfully. Status:", response.status_code) - return None - except Exception as e: - print("Could not get OSD", e) - raise + body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetOsd', body) def get_mask(self) -> json or None: """ @@ -257,17 +220,8 @@ def get_mask(self) -> json or None: }] :return: json or None """ - try: - param = {"cmd": "GetMask", "token": self.token} - body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not get Mask from camera successfully. Status:", response.status_code) - return None - except Exception as e: - print("Could not get mask", e) - raise + body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetMask', body) ########### # SET @@ -286,28 +240,19 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo :param osd_time_pos: string time position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] :return: """ - try: - param = {"cmd": "setOsd", "token": self.token} - 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} - } - } - }] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - r_data = json.loads(response.text) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not set OSD. Camera responded with status:", r_data["value"]) - return False - print("Could not set OSD. Status:", response.status_code) - return False - except Exception as e: - print("Could not set OSD", e) - raise + 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) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not set OSD. Camera responded with status:", r_data["value"]) + return False ########### # SYSTEM @@ -325,8 +270,6 @@ def get_general_system(self) -> json or None: response = Request.post(self.url, data=body, params=param) if response.status_code == 200: return json.loads(response.text) - print("Could not retrieve general information from camera successfully. Status:", response.status_code) - return None except Exception as e: print("Could not get General System settings\n", e) raise @@ -347,17 +290,8 @@ def get_performance(self) -> json or None: }] :return: json or None """ - try: - param = {"cmd": "GetPerformance", "token": self.token} - body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Cound not retrieve performance information from camera successfully. Status:", response.status_code) - return None - except Exception as e: - print("Could not get performance", e) - raise + body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] + return self._execute_command('GetPerformance', body) def get_information(self) -> json or None: """ @@ -386,17 +320,8 @@ def get_information(self) -> json or None: }] :return: json or None """ - try: - param = {"cmd": "GetDevInfo", "token": self.token} - body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not retrieve camera information. Status:", response.status_code) - return None - except Exception as e: - print("Could not get device information", e) - raise + body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] + return self._execute_command('GetDevInfo', body) ########### # SET @@ -406,17 +331,8 @@ def reboot_camera(self) -> bool: Reboots the camera :return: bool """ - try: - param = {"cmd": "Reboot", "token": self.token} - body = [{"cmd": "Reboot", "action": 0, "param": {}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return True - print("Something went wrong. Could not reboot camera. Status:", response.status_code) - return False - except Exception as e: - print("Could not reboot camera", e) - raise + body = [{"cmd": "Reboot", "action": 0, "param": {}}] + return self._execute_command('Reboot', body) ########## # User @@ -441,17 +357,8 @@ def get_online_user(self) -> json or None: }] :return: json or None """ - try: - param = {"cmd": "GetOnline", "token": self.token} - body = [{"cmd": "GetOnline", "action": 1, "param": {}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not retrieve online user from camera. Status:", response.status_code) - return None - except Exception as e: - print("Could not get online user", e) - raise + body = [{"cmd": "GetOnline", "action": 1, "param": {}}] + return self._execute_command('GetOnline', body) def get_users(self) -> json or None: """ @@ -481,17 +388,8 @@ def get_users(self) -> json or None: }] :return: json or None """ - try: - param = {"cmd": "GetUser", "token": self.token} - body = [{"cmd": "GetUser", "action": 1, "param": {}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not retrieve users from camera. Status:", response.status_code) - return None - except Exception as e: - print("Could not get users", e) - raise + body = [{"cmd": "GetUser", "action": 1, "param": {}}] + return self._execute_command('GetUser', body) ########## # SET @@ -504,22 +402,13 @@ def add_user(self, username: str, password: str, level: str = "guest") -> bool: :param level: The privilege level 'guest' or 'admin'. Default is 'guest' :return: bool """ - try: - param = {"cmd": "AddUser", "token": self.token} - body = [{"cmd": "AddUser", "action": 0, - "param": {"User": {"userName": username, "password": password, "level": level}}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - r_data = json.loads(response.text) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not add user. Camera responded with:", r_data["value"]) - return False - print("Something went wrong. Could not add user. Status:", response.status_code) - return False - except Exception as e: - print("Could not add user", e) - raise + body = [{"cmd": "AddUser", "action": 0, + "param": {"User": {"userName": username, "password": password, "level": level}}}] + r_data = self._execute_command('AddUser', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not add user. Camera responded with:", r_data["value"]) + return False def modify_user(self, username: str, password: str) -> bool: """ @@ -528,20 +417,12 @@ def modify_user(self, username: str, password: str) -> bool: :param password: The new password :return: bool """ - try: - param = {"cmd": "ModifyUser", "token": self.token} - body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - r_data = json.loads(response.text) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not modify user:", username, "\nCamera responded with:", r_data["value"]) - print("Something went wrong. Could not modify user. Status:", response.status_code) - return False - except Exception as e: - print("Could not modify user", e) - raise + body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] + r_data = self._execute_command('ModifyUser', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not modify user:", username, "\nCamera responded with:", r_data["value"]) + return False def delete_user(self, username: str) -> bool: """ @@ -549,19 +430,12 @@ def delete_user(self, username: str) -> bool: :param username: The user which would want to be deleted :return: bool """ - try: - param = {"cmd": "DelUser", "token": self.token} - body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - r_data = json.loads(response.text) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not delete user:", username, "\nCamera responded with:", r_data["value"]) - return False - except Exception as e: - print("Could not delete user", e) - raise + body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] + r_data = self._execute_command('DelUser', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not delete user:", username, "\nCamera responded with:", r_data["value"]) + return False ########## # Image Data @@ -614,17 +488,8 @@ def get_hdd_info(self) -> json or None: :return: json or None """ - try: - param = {"cmd": "GetHddInfo", "token": self.token} - body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not retrieve HDD/SD info from camera successfully. Status:", response.status_code) - return None - except Exception as e: - print("Could not get HDD/SD card information", e) - raise + body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] + return self._execute_command('GetHddInfo', body) def format_hdd(self, hdd_id: [int] = [0]) -> bool: """ @@ -632,21 +497,13 @@ def format_hdd(self, hdd_id: [int] = [0]) -> bool: :param hdd_id: List of id's specified by the camera with get_hdd_info api. Default is 0 (SD card) :return: bool """ - try: - param = {"cmd": "Format", "token": self.token} - body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - r_data = json.loads(response.text) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not format HDD/SD. Camera responded with:", r_data["value"]) - return False - print("Could not format HDD/SD. Status:", response.status_code) - return False - except Exception as e: - print("Could not format HDD/SD", e) - raise + body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}] + r_data = self._execute_command('Format', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not format HDD/SD. Camera responded with:", r_data["value"]) + return False + ########### # Recording @@ -825,17 +682,8 @@ def get_recording_encoding(self) -> json or None: :return: json or None """ - try: - param = {"cmd": "GetEnc", "token": self.token} - body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not retrieve recording encoding data. Status:", response.status_code) - return None - except Exception as e: - print("Could not get recording encoding", e) - raise + body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetEnc', body) def get_recording_advanced(self) -> json or None: """ @@ -883,16 +731,8 @@ def get_recording_advanced(self) -> json or None: :return: json or None """ - try: - param = {"cmd": "GetRec", "token": self.token} - body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}] - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not retrieve advanced recording. Status:", response.status_code) - return None - except Exception as e: - print("Could not get advanced recoding", e) + body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetRec', body) ########### # RTSP Stream From 7512ce40ea826854832865ba91b85b6c2e63353c Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Wed, 4 Mar 2020 12:28:04 +0100 Subject: [PATCH 014/103] Remove unusable code Since the Request class catches/re-raises every errors (including http non-200 status codes) some lines could never be reached. Some of these lines were already removed in the previous commit. --- APIHandler.py | 16 +++------------- resthandle.py | 2 +- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index 66e3ed8..dda8567 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -122,14 +122,8 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port }}}] param = {"token": self.token} response = Request.post(self.url, data=body, params=param) - if response is not None: - if response.status_code == 200: - print("Successfully Set Network Ports") - return True - else: - print("Something went wront\nStatus Code:", response.status_code) - return False - return False + print("Successfully Set Network Ports") + return True except Exception as e: print("Setting Network Port Error\n", e) raise @@ -159,10 +153,7 @@ def get_net_ports(self) -> json or None: {"cmd": "GetP2p", "action": 0, "param": {}}] param = {"token": self.token} response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return json.loads(response.text) - print("Could not get network ports data. Status:", response.status_code) - return None + return json.loads(response.text) except Exception as e: print("Get Network Ports", e) @@ -447,7 +438,6 @@ def get_snap(self, timeout: int = 3) -> Image or None: :return: Image or None """ try: - randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) snap = self.url + "?cmd=Snap&channel=0&rs=" \ + randomstr \ diff --git a/resthandle.py b/resthandle.py index a5433b1..f8ed674 100644 --- a/resthandle.py +++ b/resthandle.py @@ -28,7 +28,7 @@ def post(url: str, data, params=None) -> requests.Response or None: if r.status_code == 200: return r else: - raise ValueError("Status: ", r.status_code) + raise ValueError(f"Http Request had non-200 Status: {r.status_code}", r.status_code) except Exception as e: print("Post Error\n", e) raise From 6548040810990133bb59440f522509dbd63a3b1e Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Wed, 4 Mar 2020 12:32:40 +0100 Subject: [PATCH 015/103] Add option for multi-step commands This option allows to send multi-step commands. These commands have no 'cmd' URL parameter. --- APIHandler.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index dda8567..bbf9836 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -72,17 +72,23 @@ def login(self) -> bool: print("Error Login\n", e) raise - def _execute_command(self, command, data): + def _execute_command(self, command, data, multi=False): """ - Send a POST request to the IP camera with given JSON body and + Send a POST request to the IP camera with given data. :param command: name of the command to send - :param data: object to send to the camera - :return: response as python object - """ + :param data: object to send to the camera (send as json) + :param multi: whether the given command name should be added to the + url parameters of the request. Defaults to False. (Some multi-step + commands seem to not have a single command name) + :return: response JSON as python object + """ + params = {"token": self.token, 'cmd': command} + if multi: + del params['cmd'] try: if self.token is None: raise ValueError("Login first") - response = Request.post(self.url, data=data, params={"cmd": command, "token": self.token}) + response = Request.post(self.url, data=data, params=params) return json.loads(response.text) except Exception as e: print(f"Command {command} failed: {e}") From 7761ca7c1659f10776d0b19d6511b263375d7af9 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Wed, 4 Mar 2020 12:35:41 +0100 Subject: [PATCH 016/103] Use build-in json decoding of requests See https://2.python-requests.org/en/master/user/quickstart/#json-response-content --- APIHandler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index bbf9836..8edc58e 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -57,7 +57,7 @@ def login(self) -> bool: param = {"cmd": "Login", "token": "null"} response = Request.post(self.url, data=body, params=param) if response is not None: - data = json.loads(response.text)[0] + data = response.json()[0] code = data["code"] if int(code) == 0: self.token = data["value"]["Token"]["name"] @@ -89,7 +89,7 @@ def _execute_command(self, command, data, multi=False): if self.token is None: raise ValueError("Login first") response = Request.post(self.url, data=data, params=params) - return json.loads(response.text) + return response.json() except Exception as e: print(f"Command {command} failed: {e}") raise @@ -159,7 +159,7 @@ def get_net_ports(self) -> json or None: {"cmd": "GetP2p", "action": 0, "param": {}}] param = {"token": self.token} response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) + return response.json() except Exception as e: print("Get Network Ports", e) @@ -266,7 +266,7 @@ def get_general_system(self) -> json or None: param = {"token": self.token} response = Request.post(self.url, data=body, params=param) if response.status_code == 200: - return json.loads(response.text) + return response.json() except Exception as e: print("Could not get General System settings\n", e) raise From c321333b1045e9ab9cf64406e5fd4c21a4d5d410 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Wed, 4 Mar 2020 12:44:54 +0100 Subject: [PATCH 017/103] Implement multi-step commands using generic method --- APIHandler.py | 74 +++++++++++++++++---------------------------------- 1 file changed, 24 insertions(+), 50 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index 8edc58e..e8cfcb5 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -114,25 +114,17 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port :type http_port: int :return: bool """ - try: - if self.token is None: - raise ValueError("Login first") - - body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": { - "httpPort": http_port, - "httpsPort": https_port, - "mediaPort": media_port, - "onvifPort": onvif_port, - "rtmpPort": rtmp_port, - "rtspPort": rtsp_port - }}}] - param = {"token": self.token} - response = Request.post(self.url, data=body, params=param) - print("Successfully Set Network Ports") - return True - except Exception as e: - print("Setting Network Port Error\n", e) - raise + body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": { + "httpPort": http_port, + "httpsPort": https_port, + "mediaPort": media_port, + "onvifPort": onvif_port, + "rtmpPort": rtmp_port, + "rtspPort": rtsp_port + }}}] + self._execute_command('SetNetPort', body, multi=True) + print("Successfully Set Network Ports") + return True def set_wifi(self, ssid, password) -> json or None: body = [{"cmd": "SetWifi", "action": 0, "param": { @@ -150,18 +142,10 @@ def get_net_ports(self) -> json or None: Get network ports :return: """ - try: - if self.token is not None: - raise ValueError("Login first") - - body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, - {"cmd": "GetUpnp", "action": 0, "param": {}}, - {"cmd": "GetP2p", "action": 0, "param": {}}] - param = {"token": self.token} - response = Request.post(self.url, data=body, params=param) - return response.json() - except Exception as e: - print("Get Network Ports", e) + body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, + {"cmd": "GetUpnp", "action": 0, "param": {}}, + {"cmd": "GetP2p", "action": 0, "param": {}}] + return self._execute_command('GetNetPort', body, multi=True) def get_wifi(self): body = [{"cmd": "GetWifi", "action": 1, "param": {}}] @@ -237,14 +221,13 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo :param osd_time_pos: string time position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] :return: """ - 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) if r_data["value"]["rspCode"] == "200": return True @@ -259,17 +242,8 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo # GET ########### def get_general_system(self) -> json or None: - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}] - param = {"token": self.token} - response = Request.post(self.url, data=body, params=param) - if response.status_code == 200: - return response.json() - except Exception as e: - print("Could not get General System settings\n", e) - raise + 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) -> json or None: """ From 3f6e0b2b7e915df8d41fdade690f2ec6bd1f7199 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Wed, 4 Mar 2020 13:02:18 +0100 Subject: [PATCH 018/103] Move response examples in pretty-formatted single files --- APIHandler.py | 327 +--------------------- examples/response/GetDevInfo.json | 26 ++ examples/response/GetEnc.json | 377 ++++++++++++++++++++++++++ examples/response/GetHddInfo.json | 17 ++ examples/response/GetMask.json | 40 +++ examples/response/GetOnline.json | 17 ++ examples/response/GetOsd.json | 67 +++++ examples/response/GetPerformance.json | 13 + examples/response/GetRec.json | 45 +++ examples/response/GetUser.json | 35 +++ 10 files changed, 650 insertions(+), 314 deletions(-) create mode 100644 examples/response/GetDevInfo.json create mode 100644 examples/response/GetEnc.json create mode 100644 examples/response/GetHddInfo.json create mode 100644 examples/response/GetMask.json create mode 100644 examples/response/GetOnline.json create mode 100644 examples/response/GetOsd.json create mode 100644 examples/response/GetPerformance.json create mode 100644 examples/response/GetRec.json create mode 100644 examples/response/GetUser.json diff --git a/APIHandler.py b/APIHandler.py index e8cfcb5..423e2c8 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -23,7 +23,7 @@ class APIHandler: """ - def __init__(self, ip: str, username: str, password: str, https = False, **kwargs): + def __init__(self, ip: str, username: str, password: str, https=False, **kwargs): """ Initialise the Camera API Handler (maps api calls into python) :param ip: @@ -165,18 +165,7 @@ def scan_wifi(self): def get_osd(self) -> json or None: """ Get OSD information. - Response data format is as follows: - [{"cmd" : "GetOsd","code" : 0, "initial" : { - "Osd" : {"bgcolor" : 0,"channel" : 0,"osdChannel" : {"enable" : 1,"name" : "Camera1","pos" : "Lower Right"}, - "osdTime" : {"enable" : 1,"pos" : "Top Center"} - }},"range" : {"Osd" : {"bgcolor" : "boolean","channel" : 0,"osdChannel" : {"enable" : "boolean","name" : {"maxLen" : 31}, - "pos" : ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] - }, - "osdTime" : {"enable" : "boolean","pos" : ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] - } - }},"value" : {"Osd" : {"bgcolor" : 0,"channel" : 0,"osdChannel" : {"enable" : 0,"name" : "FarRight","pos" : "Lower Right"}, - "osdTime" : {"enable" : 0,"pos" : "Top Center"} - }}}] + See examples/response/GetOsd.json for example response data. :return: json or None """ body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] @@ -184,21 +173,8 @@ def get_osd(self) -> json or None: def get_mask(self) -> json or None: """ - Get the camera mask information - Response data format is as follows: - [{"cmd" : "GetMask","code" : 0,"initial" : { - "Mask" : { - "area" : [{"block" : {"height" : 0,"width" : 0,"x" : 0,"y" : 0},"screen" : {"height" : 0,"width" : 0}}], - "channel" : 0, - "enable" : 0 - } - },"range" : {"Mask" : {"channel" : 0,"enable" : "boolean","maxAreas" : 4}},"value" : { - "Mask" : { - "area" : null, - "channel" : 0, - "enable" : 0} - } - }] + Get the camera mask information. + See examples/response/GetMask.json for example response data. :return: json or None """ body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] @@ -226,7 +202,7 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo "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) if r_data["value"]["rspCode"] == "200": @@ -248,17 +224,7 @@ def get_general_system(self) -> json or None: def get_performance(self) -> json or None: """ Get a snapshot of the current performance of the camera. - Response data format is as follows: - [{"cmd" : "GetPerformance", - "code" : 0, - "value" : { - "Performance" : { - "codecRate" : 2154, - "cpuUsed" : 14, - "netThroughput" : 0 - } - } - }] + See examples/response/GetPerformance.json for example response data. :return: json or None """ body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] @@ -267,28 +233,7 @@ def get_performance(self) -> json or None: def get_information(self) -> json or None: """ Get the camera information - Response data format is as follows: - [{"cmd" : "GetDevInfo","code" : 0,"value" : { - "DevInfo" : { - "B485" : 0, - "IOInputNum" : 0, - "IOOutputNum" : 0, - "audioNum" : 0, - "buildDay" : "build 18081408", - "cfgVer" : "v2.0.0.0", - "channelNum" : 1, - "detail" : "IPC_3816M100000000100000", - "diskNum" : 1, - "firmVer" : "v2.0.0.1389_18081408", - "hardVer" : "IPC_3816M", - "model" : "RLC-411WS", - "name" : "Camera1_withpersonality", - "serial" : "00000000000000", - "type" : "IPC", - "wifi" : 1 - } - } - }] + See examples/response/GetDevInfo.json for example response data. :return: json or None """ body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] @@ -315,17 +260,7 @@ def reboot_camera(self) -> bool: def get_online_user(self) -> json or None: """ Return a list of current logged-in users in json format - Response data format is as follows: - [{"cmd" : "GetOnline","code" : 0,"value" : { - "User" : [{ - "canbeDisconn" : 0, - "ip" : "192.168.1.100", - "level" : "admin", - "sessionId" : 1000, - "userName" : "admin" - }] - } - }] + See examples/response/GetOnline.json for example response data. :return: json or None """ body = [{"cmd": "GetOnline", "action": 1, "param": {}}] @@ -333,30 +268,8 @@ def get_online_user(self) -> json or None: def get_users(self) -> json or None: """ - Return a list of user accounts from the camera in json format - Response data format is as follows: - [{"cmd" : "GetUser","code" : 0,"initial" : { - "User" : { - "level" : "guest" - }}, - "range" : {"User" : { - "level" : [ "guest", "admin" ], - "password" : { - "maxLen" : 31, - "minLen" : 6 - }, - "userName" : { - "maxLen" : 31, - "minLen" : 1 - }} - },"value" : { - "User" : [ - { - "level" : "admin", - "userName" : "admin" - }] - } - }] + Return a list of user accounts from the camera in json format. + See examples/response/GetUser.json for example response data. :return: json or None """ body = [{"cmd": "GetUser", "action": 1, "param": {}}] @@ -442,20 +355,7 @@ def get_snap(self, timeout: int = 3) -> Image or None: def get_hdd_info(self) -> json or None: """ Gets all HDD and SD card information from Camera - Response data format is as follows: - [{"cmd" : "GetHddInfo", - "code" : 0, - "value" : { - "HddInfo" : [{ - "capacity" : 15181, - "format" : 1, - "id" : 0, - "mount" : 1, - "size" : 15181 - }] - } - }] - + See examples/response/GetHddInfo.json for example response data. :return: json or None """ body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] @@ -474,7 +374,6 @@ def format_hdd(self, hdd_id: [int] = [0]) -> bool: print("Could not format HDD/SD. Camera responded with:", r_data["value"]) return False - ########### # Recording ########### @@ -489,167 +388,7 @@ def format_hdd(self, hdd_id: [int] = [0]) -> bool: def get_recording_encoding(self) -> json or None: """ Get the current camera encoding settings for "Clear" and "Fluent" profiles. - Response data format is as follows: - [{ - "cmd" : "GetEnc", - "code" : 0, - "initial" : { - "Enc" : { - "audio" : 0, - "channel" : 0, - "mainStream" : { - "bitRate" : 4096, - "frameRate" : 15, - "profile" : "High", - "size" : "3072*1728" - }, - "subStream" : { - "bitRate" : 160, - "frameRate" : 7, - "profile" : "High", - "size" : "640*360" - } - } - }, - "range" : { - "Enc" : [ - { - "audio" : "boolean", - "mainStream" : { - "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], - "default" : { - "bitRate" : 4096, - "frameRate" : 15 - }, - "frameRate" : [ 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "3072*1728" - }, - "subStream" : { - "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], - "default" : { - "bitRate" : 160, - "frameRate" : 7 - }, - "frameRate" : [ 15, 10, 7, 4 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "640*360" - } - }, - { - "audio" : "boolean", - "mainStream" : { - "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], - "default" : { - "bitRate" : 4096, - "frameRate" : 15 - }, - "frameRate" : [ 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "2592*1944" - }, - "subStream" : { - "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], - "default" : { - "bitRate" : 160, - "frameRate" : 7 - }, - "frameRate" : [ 15, 10, 7, 4 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "640*360" - } - }, - { - "audio" : "boolean", - "mainStream" : { - "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], - "default" : { - "bitRate" : 3072, - "frameRate" : 15 - }, - "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "2560*1440" - }, - "subStream" : { - "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], - "default" : { - "bitRate" : 160, - "frameRate" : 7 - }, - "frameRate" : [ 15, 10, 7, 4 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "640*360" - } - }, - { - "audio" : "boolean", - "mainStream" : { - "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], - "default" : { - "bitRate" : 3072, - "frameRate" : 15 - }, - "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "2048*1536" - }, - "subStream" : { - "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], - "default" : { - "bitRate" : 160, - "frameRate" : 7 - }, - "frameRate" : [ 15, 10, 7, 4 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "640*360" - } - }, - { - "audio" : "boolean", - "mainStream" : { - "bitRate" : [ 1024, 1536, 2048, 3072, 4096, 5120, 6144, 7168, 8192 ], - "default" : { - "bitRate" : 3072, - "frameRate" : 15 - }, - "frameRate" : [ 30, 22, 20, 18, 16, 15, 12, 10, 8, 6, 4, 2 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "2304*1296" - }, - "subStream" : { - "bitRate" : [ 64, 128, 160, 192, 256, 384, 512 ], - "default" : { - "bitRate" : 160, - "frameRate" : 7 - }, - "frameRate" : [ 15, 10, 7, 4 ], - "profile" : [ "Base", "Main", "High" ], - "size" : "640*360" - } - } - ] - }, - "value" : { - "Enc" : { - "audio" : 0, - "channel" : 0, - "mainStream" : { - "bitRate" : 2048, - "frameRate" : 20, - "profile" : "Main", - "size" : "3072*1728" - }, - "subStream" : { - "bitRate" : 64, - "frameRate" : 4, - "profile" : "High", - "size" : "640*360" - } - } - } - }] - + See examples/response/GetEnc.json for example response data. :return: json or None """ body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] @@ -658,47 +397,7 @@ def get_recording_encoding(self) -> json or None: def get_recording_advanced(self) -> json or None: """ Get recording advanced setup data - Response data format is as follows: - [{ - "cmd" : "GetRec", - "code" : 0, - "initial" : { - "Rec" : { - "channel" : 0, - "overwrite" : 1, - "postRec" : "15 Seconds", - "preRec" : 1, - "schedule" : { - "enable" : 1, - "table" : "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" - } - } - }, - "range" : { - "Rec" : { - "channel" : 0, - "overwrite" : "boolean", - "postRec" : [ "15 Seconds", "30 Seconds", "1 Minute" ], - "preRec" : "boolean", - "schedule" : { - "enable" : "boolean" - } - } - }, - "value" : { - "Rec" : { - "channel" : 0, - "overwrite" : 1, - "postRec" : "15 Seconds", - "preRec" : 1, - "schedule" : { - "enable" : 1, - "table" : "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - } - } - } - }] - + See examples/response/GetRec.json for example response data. :return: json or None """ body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}] diff --git a/examples/response/GetDevInfo.json b/examples/response/GetDevInfo.json new file mode 100644 index 0000000..9018183 --- /dev/null +++ b/examples/response/GetDevInfo.json @@ -0,0 +1,26 @@ +[ + { + "cmd": "GetDevInfo", + "code": 0, + "value": { + "DevInfo": { + "B485": 0, + "IOInputNum": 0, + "IOOutputNum": 0, + "audioNum": 0, + "buildDay": "build 18081408", + "cfgVer": "v2.0.0.0", + "channelNum": 1, + "detail": "IPC_3816M100000000100000", + "diskNum": 1, + "firmVer": "v2.0.0.1389_18081408", + "hardVer": "IPC_3816M", + "model": "RLC-411WS", + "name": "Camera1_withpersonality", + "serial": "00000000000000", + "type": "IPC", + "wifi": 1 + } + } + } +] diff --git a/examples/response/GetEnc.json b/examples/response/GetEnc.json new file mode 100644 index 0000000..547ed6f --- /dev/null +++ b/examples/response/GetEnc.json @@ -0,0 +1,377 @@ +[ + { + "cmd": "GetEnc", + "code": 0, + "initial": { + "Enc": { + "audio": 0, + "channel": 0, + "mainStream": { + "bitRate": 4096, + "frameRate": 15, + "profile": "High", + "size": "3072*1728" + }, + "subStream": { + "bitRate": 160, + "frameRate": 7, + "profile": "High", + "size": "640*360" + } + } + }, + "range": { + "Enc": [ + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 4096, + "frameRate": 15 + }, + "frameRate": [ + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "3072*1728" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 4096, + "frameRate": 15 + }, + "frameRate": [ + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2592*1944" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 3072, + "frameRate": 15 + }, + "frameRate": [ + 30, + 22, + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2560*1440" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 3072, + "frameRate": 15 + }, + "frameRate": [ + 30, + 22, + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2048*1536" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 3072, + "frameRate": 15 + }, + "frameRate": [ + 30, + 22, + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2304*1296" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + } + ] + }, + "value": { + "Enc": { + "audio": 0, + "channel": 0, + "mainStream": { + "bitRate": 2048, + "frameRate": 20, + "profile": "Main", + "size": "3072*1728" + }, + "subStream": { + "bitRate": 64, + "frameRate": 4, + "profile": "High", + "size": "640*360" + } + } + } + } +] diff --git a/examples/response/GetHddInfo.json b/examples/response/GetHddInfo.json new file mode 100644 index 0000000..d95b7aa --- /dev/null +++ b/examples/response/GetHddInfo.json @@ -0,0 +1,17 @@ +[ + { + "cmd": "GetHddInfo", + "code": 0, + "value": { + "HddInfo": [ + { + "capacity": 15181, + "format": 1, + "id": 0, + "mount": 1, + "size": 15181 + } + ] + } + } +] diff --git a/examples/response/GetMask.json b/examples/response/GetMask.json new file mode 100644 index 0000000..beb345a --- /dev/null +++ b/examples/response/GetMask.json @@ -0,0 +1,40 @@ +[ + { + "cmd": "GetMask", + "code": 0, + "initial": { + "Mask": { + "area": [ + { + "block": { + "height": 0, + "width": 0, + "x": 0, + "y": 0 + }, + "screen": { + "height": 0, + "width": 0 + } + } + ], + "channel": 0, + "enable": 0 + } + }, + "range": { + "Mask": { + "channel": 0, + "enable": "boolean", + "maxAreas": 4 + } + }, + "value": { + "Mask": { + "area": null, + "channel": 0, + "enable": 0 + } + } + } +] diff --git a/examples/response/GetOnline.json b/examples/response/GetOnline.json new file mode 100644 index 0000000..34f59d1 --- /dev/null +++ b/examples/response/GetOnline.json @@ -0,0 +1,17 @@ +[ + { + "cmd": "GetOnline", + "code": 0, + "value": { + "User": [ + { + "canbeDisconn": 0, + "ip": "192.168.1.100", + "level": "admin", + "sessionId": 1000, + "userName": "admin" + } + ] + } + } +] diff --git a/examples/response/GetOsd.json b/examples/response/GetOsd.json new file mode 100644 index 0000000..e925b90 --- /dev/null +++ b/examples/response/GetOsd.json @@ -0,0 +1,67 @@ +[ + { + "cmd": "GetOsd", + "code": 0, + "initial": { + "Osd": { + "bgcolor": 0, + "channel": 0, + "osdChannel": { + "enable": 1, + "name": "Camera1", + "pos": "Lower Right" + }, + "osdTime": { + "enable": 1, + "pos": "Top Center" + } + } + }, + "range": { + "Osd": { + "bgcolor": "boolean", + "channel": 0, + "osdChannel": { + "enable": "boolean", + "name": { + "maxLen": 31 + }, + "pos": [ + "Upper Left", + "Top Center", + "Upper Right", + "Lower Left", + "Bottom Center", + "Lower Right" + ] + }, + "osdTime": { + "enable": "boolean", + "pos": [ + "Upper Left", + "Top Center", + "Upper Right", + "Lower Left", + "Bottom Center", + "Lower Right" + ] + } + } + }, + "value": { + "Osd": { + "bgcolor": 0, + "channel": 0, + "osdChannel": { + "enable": 0, + "name": "FarRight", + "pos": "Lower Right" + }, + "osdTime": { + "enable": 0, + "pos": "Top Center" + } + } + } + } +] \ No newline at end of file diff --git a/examples/response/GetPerformance.json b/examples/response/GetPerformance.json new file mode 100644 index 0000000..73bc9ba --- /dev/null +++ b/examples/response/GetPerformance.json @@ -0,0 +1,13 @@ +[ + { + "cmd": "GetPerformance", + "code": 0, + "value": { + "Performance": { + "codecRate": 2154, + "cpuUsed": 14, + "netThroughput": 0 + } + } + } +] diff --git a/examples/response/GetRec.json b/examples/response/GetRec.json new file mode 100644 index 0000000..623c353 --- /dev/null +++ b/examples/response/GetRec.json @@ -0,0 +1,45 @@ +[ + { + "cmd": "GetRec", + "code": 0, + "initial": { + "Rec": { + "channel": 0, + "overwrite": 1, + "postRec": "15 Seconds", + "preRec": 1, + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + } + } + }, + "range": { + "Rec": { + "channel": 0, + "overwrite": "boolean", + "postRec": [ + "15 Seconds", + "30 Seconds", + "1 Minute" + ], + "preRec": "boolean", + "schedule": { + "enable": "boolean" + } + } + }, + "value": { + "Rec": { + "channel": 0, + "overwrite": 1, + "postRec": "15 Seconds", + "preRec": 1, + "schedule": { + "enable": 1, + "table": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + } + } +] diff --git a/examples/response/GetUser.json b/examples/response/GetUser.json new file mode 100644 index 0000000..d3d77e7 --- /dev/null +++ b/examples/response/GetUser.json @@ -0,0 +1,35 @@ +[ + { + "cmd": "GetUser", + "code": 0, + "initial": { + "User": { + "level": "guest" + } + }, + "range": { + "User": { + "level": [ + "guest", + "admin" + ], + "password": { + "maxLen": 31, + "minLen": 6 + }, + "userName": { + "maxLen": 31, + "minLen": 1 + } + } + }, + "value": { + "User": [ + { + "level": "admin", + "userName": "admin" + } + ] + } + } +] From 17fb46f73a0ed1e9091e65cb5fd3a45678714a72 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Wed, 4 Mar 2020 13:09:58 +0100 Subject: [PATCH 019/103] Fix return value hints None is never returned, its just possible that a exception is raised. --- APIHandler.py | 57 ++++++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/APIHandler.py b/APIHandler.py index 423e2c8..2417fbf 100644 --- a/APIHandler.py +++ b/APIHandler.py @@ -126,7 +126,7 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port print("Successfully Set Network Ports") return True - def set_wifi(self, ssid, password) -> json or None: + def set_wifi(self, ssid, password) -> json: body = [{"cmd": "SetWifi", "action": 0, "param": { "Wifi": { "ssid": ssid, @@ -137,10 +137,10 @@ def set_wifi(self, ssid, password) -> json or None: ########### # GET ########### - def get_net_ports(self) -> json or None: + def get_net_ports(self) -> json: """ Get network ports - :return: + :return: response json """ body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, {"cmd": "GetUpnp", "action": 0, "param": {}}, @@ -162,20 +162,20 @@ def scan_wifi(self): ########### # GET ########### - def get_osd(self) -> json or None: + def get_osd(self) -> json: """ Get OSD information. See examples/response/GetOsd.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetOsd', body) - def get_mask(self) -> json or None: + def get_mask(self) -> json: """ Get the camera mask information. See examples/response/GetMask.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetMask', body) @@ -195,7 +195,7 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo :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"] - :return: + :return: whether the action was successful """ body = [{"cmd": "SetOsd", "action": 1, "param": { "Osd": {"bgcolor": bg_color, "channel": channel, @@ -217,24 +217,25 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo ########### # GET ########### - def get_general_system(self) -> json or None: + def get_general_system(self) -> json: + """: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) -> json or None: + def get_performance(self) -> json: """ Get a snapshot of the current performance of the camera. See examples/response/GetPerformance.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] return self._execute_command('GetPerformance', body) - def get_information(self) -> json or None: + def get_information(self) -> json: """ Get the camera information See examples/response/GetDevInfo.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] return self._execute_command('GetDevInfo', body) @@ -242,10 +243,10 @@ def get_information(self) -> json or None: ########### # SET ########### - def reboot_camera(self) -> bool: + def reboot_camera(self) -> json: """ Reboots the camera - :return: bool + :return: response json """ body = [{"cmd": "Reboot", "action": 0, "param": {}}] return self._execute_command('Reboot', body) @@ -257,20 +258,20 @@ def reboot_camera(self) -> bool: ########## # GET ########## - def get_online_user(self) -> json or None: + def get_online_user(self) -> json: """ Return a list of current logged-in users in json format See examples/response/GetOnline.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetOnline", "action": 1, "param": {}}] return self._execute_command('GetOnline', body) - def get_users(self) -> json or None: + def get_users(self) -> json: """ Return a list of user accounts from the camera in json format. See examples/response/GetUser.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetUser", "action": 1, "param": {}}] return self._execute_command('GetUser', body) @@ -284,7 +285,7 @@ def add_user(self, username: str, password: str, level: str = "guest") -> bool: :param username: The user's username :param password: The user's password :param level: The privilege level 'guest' or 'admin'. Default is 'guest' - :return: bool + :return: whether the user was added successfully """ body = [{"cmd": "AddUser", "action": 0, "param": {"User": {"userName": username, "password": password, "level": level}}}] @@ -299,7 +300,7 @@ def modify_user(self, username: str, password: str) -> bool: Modify the user's password by specifying their username :param username: The user which would want to be modified :param password: The new password - :return: bool + :return: whether the user was modified successfully """ body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] r_data = self._execute_command('ModifyUser', body) @@ -312,7 +313,7 @@ def delete_user(self, username: str) -> bool: """ Delete a user by specifying their username :param username: The user which would want to be deleted - :return: bool + :return: whether the user was deleted successfully """ body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] r_data = self._execute_command('DelUser', body) @@ -352,11 +353,11 @@ def get_snap(self, timeout: int = 3) -> Image or None: ######### # Device ######### - def get_hdd_info(self) -> json or None: + def get_hdd_info(self) -> json: """ Gets all HDD and SD card information from Camera See examples/response/GetHddInfo.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] return self._execute_command('GetHddInfo', body) @@ -385,20 +386,20 @@ def format_hdd(self, hdd_id: [int] = [0]) -> bool: ########### # GET ########### - def get_recording_encoding(self) -> json or None: + def get_recording_encoding(self) -> json: """ Get the current camera encoding settings for "Clear" and "Fluent" profiles. See examples/response/GetEnc.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetEnc', body) - def get_recording_advanced(self) -> json or None: + def get_recording_advanced(self) -> json: """ Get recording advanced setup data See examples/response/GetRec.json for example response data. - :return: json or None + :return: response json """ body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetRec', body) From 5c0622d66927654aa6602daffab343ce586f36f2 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Fri, 6 Mar 2020 16:52:19 +0100 Subject: [PATCH 020/103] Move APIHandler to a package --- Camera.py | 2 +- APIHandler.py => api/APIHandler.py | 0 api/__init__.py | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename APIHandler.py => api/APIHandler.py (100%) create mode 100644 api/__init__.py diff --git a/Camera.py b/Camera.py index 1e8a52b..f4d6e9e 100644 --- a/Camera.py +++ b/Camera.py @@ -1,4 +1,4 @@ -from APIHandler import APIHandler +from api import APIHandler class Camera(APIHandler): diff --git a/APIHandler.py b/api/APIHandler.py similarity index 100% rename from APIHandler.py rename to api/APIHandler.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..b8cf11e --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +from .APIHandler import APIHandler \ No newline at end of file From 28c6f3f2a1ed4b596310bd624c415baece981a8a Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Fri, 6 Mar 2020 17:22:40 +0100 Subject: [PATCH 021/103] Split APIHandler into single files --- api/APIHandler.py | 365 ++-------------------------------------------- api/device.py | 23 +++ api/display.py | 47 ++++++ api/network.py | 52 +++++++ api/recording.py | 69 +++++++++ api/system.py | 32 ++++ api/user.py | 62 ++++++++ 7 files changed, 299 insertions(+), 351 deletions(-) create mode 100644 api/device.py create mode 100644 api/display.py create mode 100644 api/network.py create mode 100644 api/recording.py create mode 100644 api/system.py create mode 100644 api/user.py diff --git a/api/APIHandler.py b/api/APIHandler.py index 2417fbf..61274e7 100644 --- a/api/APIHandler.py +++ b/api/APIHandler.py @@ -1,26 +1,25 @@ -import io -import json -import random -import string -import sys -from urllib import request - -import numpy -import rtsp -from PIL import Image - -from RtspClient import RtspClient +from api.recording import RecordingAPIMixin +from .device import DeviceAPIMixin +from .display import DisplayAPIMixin +from .network import NetworkAPIMixin +from .system import SystemAPIMixin +from .user import UserAPIMixin from resthandle import Request -class APIHandler: +class APIHandler(SystemAPIMixin, + NetworkAPIMixin, + UserAPIMixin, + DeviceAPIMixin, + DisplayAPIMixin, + RecordingAPIMixin): """ - The APIHandler class is the backend part of the API. + The APIHandler class is the backend part of the API, the actual API calls + are implemented in Mixins. This handles communication directly with the camera. Current camera's tested: RLC-411WS 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): @@ -41,10 +40,6 @@ def __init__(self, ip: str, username: str, password: str, https=False, **kwargs) self.password = password Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found - ########### - # Token - ########### - def login(self) -> bool: """ Get login token @@ -93,335 +88,3 @@ def _execute_command(self, command, data, multi=False): except Exception as e: print(f"Command {command} failed: {e}") raise - - ########### - # NETWORK - ########### - - ########### - # SET Network - ########### - def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port=8000, rtmp_port=1935, - rtsp_port=554) -> bool: - """ - Set network ports - If nothing is specified, the default values will be used - :param rtsp_port: int - :param rtmp_port: int - :param onvif_port: int - :param media_port: int - :param https_port: int - :type http_port: int - :return: bool - """ - body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": { - "httpPort": http_port, - "httpsPort": https_port, - "mediaPort": media_port, - "onvifPort": onvif_port, - "rtmpPort": rtmp_port, - "rtspPort": rtsp_port - }}}] - self._execute_command('SetNetPort', body, multi=True) - print("Successfully Set Network Ports") - return True - - def set_wifi(self, ssid, password) -> json: - body = [{"cmd": "SetWifi", "action": 0, "param": { - "Wifi": { - "ssid": ssid, - "password": password - }}}] - return self._execute_command('SetWifi', body) - - ########### - # GET - ########### - def get_net_ports(self) -> json: - """ - Get network ports - :return: response json - """ - body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, - {"cmd": "GetUpnp", "action": 0, "param": {}}, - {"cmd": "GetP2p", "action": 0, "param": {}}] - return self._execute_command('GetNetPort', body, multi=True) - - def get_wifi(self): - body = [{"cmd": "GetWifi", "action": 1, "param": {}}] - return self._execute_command('GetWifi', body) - - def scan_wifi(self): - body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] - return self._execute_command('ScanWifi', body) - - ########### - # Display - ########### - - ########### - # GET - ########### - def get_osd(self) -> json: - """ - Get OSD information. - See examples/response/GetOsd.json for example response data. - :return: response json - """ - body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] - return self._execute_command('GetOsd', body) - - def get_mask(self) -> json: - """ - Get the camera mask information. - See examples/response/GetMask.json for example response data. - :return: response json - """ - body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] - return self._execute_command('GetMask', body) - - ########### - # SET - ########### - 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, - osd_time_pos: str = "Lower Right") -> bool: - """ - Set OSD - :param bg_color: bool - :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_time_enabled: bool - :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} - } - }}] - r_data = self._execute_command('SetOsd', body) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not set OSD. Camera responded with status:", r_data["value"]) - return False - - ########### - # SYSTEM - ########### - - ########### - # GET - ########### - def get_general_system(self) -> json: - """: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) -> json: - """ - Get a snapshot of the current performance of the camera. - See examples/response/GetPerformance.json for example response data. - :return: response json - """ - body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] - return self._execute_command('GetPerformance', body) - - def get_information(self) -> json: - """ - Get the camera information - See examples/response/GetDevInfo.json for example response data. - :return: response json - """ - body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] - return self._execute_command('GetDevInfo', body) - - ########### - # SET - ########### - def reboot_camera(self) -> json: - """ - Reboots the camera - :return: response json - """ - body = [{"cmd": "Reboot", "action": 0, "param": {}}] - return self._execute_command('Reboot', body) - - ########## - # User - ########## - - ########## - # GET - ########## - def get_online_user(self) -> json: - """ - Return a list of current logged-in users in json format - See examples/response/GetOnline.json for example response data. - :return: response json - """ - body = [{"cmd": "GetOnline", "action": 1, "param": {}}] - return self._execute_command('GetOnline', body) - - def get_users(self) -> json: - """ - Return a list of user accounts from the camera in json format. - See examples/response/GetUser.json for example response data. - :return: response json - """ - body = [{"cmd": "GetUser", "action": 1, "param": {}}] - return self._execute_command('GetUser', body) - - ########## - # SET - ########## - def add_user(self, username: str, password: str, level: str = "guest") -> bool: - """ - Add a new user account to the camera - :param username: The user's username - :param password: The user's password - :param level: The privilege level 'guest' or 'admin'. Default is 'guest' - :return: whether the user was added successfully - """ - body = [{"cmd": "AddUser", "action": 0, - "param": {"User": {"userName": username, "password": password, "level": level}}}] - r_data = self._execute_command('AddUser', body) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not add user. Camera responded with:", r_data["value"]) - return False - - def modify_user(self, username: str, password: str) -> bool: - """ - Modify the user's password by specifying their username - :param username: The user which would want to be modified - :param password: The new password - :return: whether the user was modified successfully - """ - body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] - r_data = self._execute_command('ModifyUser', body) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not modify user:", username, "\nCamera responded with:", r_data["value"]) - return False - - def delete_user(self, username: str) -> bool: - """ - Delete a user by specifying their username - :param username: The user which would want to be deleted - :return: whether the user was deleted successfully - """ - body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] - r_data = self._execute_command('DelUser', body) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not delete user:", username, "\nCamera responded with:", r_data["value"]) - return False - - ########## - # Image Data - ########## - def get_snap(self, timeout: int = 3) -> 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 - :return: Image or None - """ - try: - randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) - snap = self.url + "?cmd=Snap&channel=0&rs=" \ - + randomstr \ - + "&user=" + self.username \ - + "&password=" + self.password - req = request.Request(snap) - req.set_proxy(Request.proxies, 'http') - reader = request.urlopen(req, timeout) - if reader.status == 200: - b = bytearray(reader.read()) - return Image.open(io.BytesIO(b)) - print("Could not retrieve data from camera successfully. Status:", reader.status) - return None - - except Exception as e: - print("Could not get Image data\n", e) - raise - - ######### - # Device - ######### - def get_hdd_info(self) -> json: - """ - Gets all HDD and SD card information from Camera - See examples/response/GetHddInfo.json for example response data. - :return: response json - """ - body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] - return self._execute_command('GetHddInfo', body) - - def format_hdd(self, hdd_id: [int] = [0]) -> 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 - """ - body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}] - r_data = self._execute_command('Format', body) - if r_data["value"]["rspCode"] == "200": - return True - print("Could not format HDD/SD. Camera responded with:", r_data["value"]) - return False - - ########### - # Recording - ########### - - ########### - # SET - ########### - - ########### - # GET - ########### - def get_recording_encoding(self) -> json: - """ - 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) -> json: - """ - 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) - - ########### - # RTSP Stream - ########### - def open_video_stream(self, profile: str = "main") -> Image: - """ - profile is "main" or "sub" - https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player - :param profile: - :return: - """ - with RtspClient(ip=self.ip, username=self.username, password=self.password, - proxies={"host": "127.0.0.1", "port": 8000}) as rtsp_client: - rtsp_client.preview() - # with rtsp.Client( - # rtsp_server_uri="rtsp://" - # + self.username + ":" - # + self.password + "@" - # + self.ip - # + ":554//h264Preview_01_" - # + profile) as client: - # return client diff --git a/api/device.py b/api/device.py new file mode 100644 index 0000000..263a999 --- /dev/null +++ b/api/device.py @@ -0,0 +1,23 @@ +class DeviceAPIMixin: + """API calls for getting device information.""" + def get_hdd_info(self) -> object: + """ + Gets all HDD and SD card information from Camera + See examples/response/GetHddInfo.json for example response data. + :return: response json + """ + body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] + return self._execute_command('GetHddInfo', body) + + def format_hdd(self, hdd_id: [int] = [0]) -> 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 + """ + body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}] + r_data = self._execute_command('Format', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not format HDD/SD. Camera responded with:", r_data["value"]) + return False diff --git a/api/display.py b/api/display.py new file mode 100644 index 0000000..72380b4 --- /dev/null +++ b/api/display.py @@ -0,0 +1,47 @@ +class DisplayAPIMixin: + """API calls related to the current image (osd, on screen display).""" + + def get_osd(self) -> object: + """ + Get OSD information. + See examples/response/GetOsd.json for example response data. + :return: response json + """ + body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetOsd', body) + + def get_mask(self) -> object: + """ + Get the camera mask information. + See examples/response/GetMask.json for example response data. + :return: response json + """ + 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, + osd_time_pos: str = "Lower Right") -> bool: + """ + Set OSD + :param bg_color: bool + :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_time_enabled: bool + :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} + } + }}] + r_data = self._execute_command('SetOsd', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not set OSD. Camera responded with status:", r_data["value"]) + return False diff --git a/api/network.py b/api/network.py new file mode 100644 index 0000000..8e1ecc5 --- /dev/null +++ b/api/network.py @@ -0,0 +1,52 @@ +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: + """ + Set network ports + If nothing is specified, the default values will be used + :param rtsp_port: int + :param rtmp_port: int + :param onvif_port: int + :param media_port: int + :param https_port: int + :type http_port: int + :return: bool + """ + body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": { + "httpPort": http_port, + "httpsPort": https_port, + "mediaPort": media_port, + "onvifPort": onvif_port, + "rtmpPort": rtmp_port, + "rtspPort": rtsp_port + }}}] + self._execute_command('SetNetPort', body, multi=True) + print("Successfully Set Network Ports") + return True + + def set_wifi(self, ssid, password) -> object: + body = [{"cmd": "SetWifi", "action": 0, "param": { + "Wifi": { + "ssid": ssid, + "password": password + }}}] + return self._execute_command('SetWifi', body) + + def get_net_ports(self) -> object: + """ + Get network ports + :return: response json + """ + body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, + {"cmd": "GetUpnp", "action": 0, "param": {}}, + {"cmd": "GetP2p", "action": 0, "param": {}}] + return self._execute_command('GetNetPort', body, multi=True) + + def get_wifi(self): + body = [{"cmd": "GetWifi", "action": 1, "param": {}}] + return self._execute_command('GetWifi', body) + + def scan_wifi(self): + body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] + return self._execute_command('ScanWifi', body) diff --git a/api/recording.py b/api/recording.py new file mode 100644 index 0000000..c204708 --- /dev/null +++ b/api/recording.py @@ -0,0 +1,69 @@ +import io +import random +import string +from urllib import request + +from PIL import Image + +from RtspClient import RtspClient +from resthandle import Request + + +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) + + ########### + # RTSP Stream + ########### + def open_video_stream(self, profile: str = "main") -> Image: + """ + profile is "main" or "sub" + https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player + :param profile: + :return: + """ + with RtspClient(ip=self.ip, username=self.username, password=self.password, + proxies={"host": "127.0.0.1", "port": 8000}) as rtsp_client: + rtsp_client.preview() + + def get_snap(self, timeout: int = 3) -> 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 + :return: Image or None + """ + randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) + snap = self.url + "?cmd=Snap&channel=0&rs=" \ + + randomstr \ + + "&user=" + self.username \ + + "&password=" + self.password + try: + req = request.Request(snap) + req.set_proxy(Request.proxies, 'http') + reader = request.urlopen(req, timeout) + if reader.status == 200: + b = bytearray(reader.read()) + return Image.open(io.BytesIO(b)) + print("Could not retrieve data from camera successfully. Status:", reader.status) + return None + + except Exception as e: + print("Could not get Image data\n", e) + raise diff --git a/api/system.py b/api/system.py new file mode 100644 index 0000000..244b849 --- /dev/null +++ b/api/system.py @@ -0,0 +1,32 @@ +class SystemAPIMixin: + """API for accessing general system information of the camera.""" + def get_general_system(self) -> object: + """: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: + """ + Get a snapshot of the current performance of the camera. + See examples/response/GetPerformance.json for example response data. + :return: response json + """ + body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] + return self._execute_command('GetPerformance', body) + + def get_information(self) -> object: + """ + Get the camera information + See examples/response/GetDevInfo.json for example response data. + :return: response json + """ + body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] + return self._execute_command('GetDevInfo', body) + + def reboot_camera(self) -> object: + """ + Reboots the camera + :return: response json + """ + body = [{"cmd": "Reboot", "action": 0, "param": {}}] + return self._execute_command('Reboot', body) diff --git a/api/user.py b/api/user.py new file mode 100644 index 0000000..7473901 --- /dev/null +++ b/api/user.py @@ -0,0 +1,62 @@ +class UserAPIMixin: + """User-related API calls.""" + def get_online_user(self) -> object: + """ + Return a list of current logged-in users in json format + See examples/response/GetOnline.json for example response data. + :return: response json + """ + body = [{"cmd": "GetOnline", "action": 1, "param": {}}] + return self._execute_command('GetOnline', body) + + def get_users(self) -> object: + """ + Return a list of user accounts from the camera in json format. + See examples/response/GetUser.json for example response data. + :return: response json + """ + body = [{"cmd": "GetUser", "action": 1, "param": {}}] + return self._execute_command('GetUser', body) + + def add_user(self, username: str, password: str, level: str = "guest") -> bool: + """ + Add a new user account to the camera + :param username: The user's username + :param password: The user's password + :param level: The privilege level 'guest' or 'admin'. Default is 'guest' + :return: whether the user was added successfully + """ + body = [{"cmd": "AddUser", "action": 0, + "param": {"User": {"userName": username, "password": password, "level": level}}}] + r_data = self._execute_command('AddUser', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not add user. Camera responded with:", r_data["value"]) + return False + + def modify_user(self, username: str, password: str) -> bool: + """ + Modify the user's password by specifying their username + :param username: The user which would want to be modified + :param password: The new password + :return: whether the user was modified successfully + """ + body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] + r_data = self._execute_command('ModifyUser', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not modify user:", username, "\nCamera responded with:", r_data["value"]) + return False + + def delete_user(self, username: str) -> bool: + """ + Delete a user by specifying their username + :param username: The user which would want to be deleted + :return: whether the user was deleted successfully + """ + body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] + r_data = self._execute_command('DelUser', body) + if r_data["value"]["rspCode"] == "200": + return True + print("Could not delete user:", username, "\nCamera responded with:", r_data["value"]) + return False From b3e6b80cac03a5cb2ff5511de88e22e2dcfcd5ca Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Fri, 6 Mar 2020 18:07:54 +0100 Subject: [PATCH 022/103] Remove default proxy --- Camera.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Camera.py b/Camera.py index f4d6e9e..a60490a 100644 --- a/Camera.py +++ b/Camera.py @@ -11,8 +11,9 @@ def __init__(self, ip, username="admin", password="", https=False): :param username: :param password: """ - # For when you need to connect to a camera behind a proxy - APIHandler.__init__(self, ip, username, password, proxy={"http": "socks5://127.0.0.1:8000"}, https=https) + # 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) From 8693daa6eaaafb6314e17d97d2a3bf47753c93f9 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Fri, 6 Mar 2020 20:18:08 +0100 Subject: [PATCH 023/103] Fix response value check These commands were not implemented like this before. At least on the camera i used for testing (RLC-411) the return value was always a list and the status code is an int. --- api/device.py | 4 ++-- api/display.py | 4 ++-- api/user.py | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/api/device.py b/api/device.py index 263a999..deee890 100644 --- a/api/device.py +++ b/api/device.py @@ -16,8 +16,8 @@ def format_hdd(self, hdd_id: [int] = [0]) -> bool: :return: bool """ body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}] - r_data = self._execute_command('Format', body) - if r_data["value"]["rspCode"] == "200": + r_data = self._execute_command('Format', body)[0] + if r_data["value"]["rspCode"] == 200: return True print("Could not format HDD/SD. Camera responded with:", r_data["value"]) return False diff --git a/api/display.py b/api/display.py index 72380b4..bf2b4ae 100644 --- a/api/display.py +++ b/api/display.py @@ -40,8 +40,8 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos} } }}] - r_data = self._execute_command('SetOsd', body) - if r_data["value"]["rspCode"] == "200": + r_data = self._execute_command('SetOsd', body)[0] + if r_data["value"]["rspCode"] == 200: return True print("Could not set OSD. Camera responded with status:", r_data["value"]) return False diff --git a/api/user.py b/api/user.py index 7473901..9d430f6 100644 --- a/api/user.py +++ b/api/user.py @@ -28,8 +28,8 @@ def add_user(self, username: str, password: str, level: str = "guest") -> bool: """ body = [{"cmd": "AddUser", "action": 0, "param": {"User": {"userName": username, "password": password, "level": level}}}] - r_data = self._execute_command('AddUser', body) - if r_data["value"]["rspCode"] == "200": + r_data = self._execute_command('AddUser', body)[0] + if r_data["value"]["rspCode"] == 200: return True print("Could not add user. Camera responded with:", r_data["value"]) return False @@ -42,8 +42,8 @@ def modify_user(self, username: str, password: str) -> bool: :return: whether the user was modified successfully """ body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] - r_data = self._execute_command('ModifyUser', body) - if r_data["value"]["rspCode"] == "200": + 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"]) return False @@ -55,8 +55,8 @@ def delete_user(self, username: str) -> bool: :return: whether the user was deleted successfully """ body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] - r_data = self._execute_command('DelUser', body) - if r_data["value"]["rspCode"] == "200": + 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"]) return False From da46f0efcace44237484bcd077d5a495fd7e29cd Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Fri, 6 Mar 2020 16:47:20 +0100 Subject: [PATCH 024/103] Add dependencies for RtspClient --- Pipfile | 3 +++ Pipfile.lock | 75 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/Pipfile b/Pipfile index 598a67f..defbd59 100644 --- a/Pipfile +++ b/Pipfile @@ -9,5 +9,8 @@ verify_ssl = true pillow = "*" pyyaml = "*" requests = "*" +numpy = "*" +opencv-python = "*" +pysocks = "*" [requires] diff --git a/Pipfile.lock b/Pipfile.lock index 8d347f1..15b8698 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,12 +1,10 @@ { "_meta": { "hash": { - "sha256": "0016c39167fc595718fc98862c09aecb3938149a1ff375707052007de7c2ad6f" + "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a" }, "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, + "requires": {}, "sources": [ { "name": "pypi", @@ -37,6 +35,66 @@ ], "version": "==2.9" }, + "numpy": { + "hashes": [ + "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6", + "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e", + "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc", + "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc", + "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a", + "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa", + "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3", + "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121", + "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971", + "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26", + "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd", + "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480", + "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec", + "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77", + "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57", + "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07", + "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572", + "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73", + "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca", + "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474", + "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5" + ], + "index": "pypi", + "version": "==1.18.1" + }, + "opencv-python": { + "hashes": [ + "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3", + "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50", + "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb", + "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b", + "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d", + "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a", + "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d", + "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951", + "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c", + "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098", + "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20", + "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f", + "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145", + "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee", + "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b", + "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e", + "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946", + "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db", + "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3", + "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268", + "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179", + "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2", + "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2", + "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9", + "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944", + "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00", + "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417" + ], + "index": "pypi", + "version": "==4.2.0.32" + }, "pillow": { "hashes": [ "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", @@ -65,6 +123,15 @@ "index": "pypi", "version": "==7.0.0" }, + "pysocks": { + "hashes": [ + "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", + "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", + "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" + ], + "index": "pypi", + "version": "==1.7.1" + }, "pyyaml": { "hashes": [ "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", From 8506e3a294778a82a722599dd3cbf40565bea140 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Wed, 4 Mar 2020 13:48:44 +0100 Subject: [PATCH 025/103] Add zoom commands --- README.md | 2 +- api/APIHandler.py | 6 ++++-- api/zoom.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 api/zoom.py diff --git a/README.md b/README.md index 7feee35..b5105d7 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ SET: - [ ] User -> Add User - [ ] User -> Manage User - [ ] Device -> HDD/SD Card -- [ ] Zoom +- [x] Zoom - [ ] Focus - [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) diff --git a/api/APIHandler.py b/api/APIHandler.py index 61274e7..eb59ec6 100644 --- a/api/APIHandler.py +++ b/api/APIHandler.py @@ -1,4 +1,5 @@ -from api.recording import RecordingAPIMixin +from .recording import RecordingAPIMixin +from .zoom import ZoomAPIMixin from .device import DeviceAPIMixin from .display import DisplayAPIMixin from .network import NetworkAPIMixin @@ -12,7 +13,8 @@ class APIHandler(SystemAPIMixin, UserAPIMixin, DeviceAPIMixin, DisplayAPIMixin, - RecordingAPIMixin): + RecordingAPIMixin, + ZoomAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. diff --git a/api/zoom.py b/api/zoom.py new file mode 100644 index 0000000..a3e2097 --- /dev/null +++ b/api/zoom.py @@ -0,0 +1,32 @@ +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_zoom(self, direction, speed=60): + op = 'ZoomInc' if direction == 'in' else 'ZoomDec' + data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": op, "speed": speed}}] + return self._execute_command('PtzCtrl', data) + + def start_zoom_in(self, speed=60): + """ + The camera zooms in until self.stop_zoom() is called. + :return: response json + """ + return self._start_zoom('in', speed=speed) + + def start_zoom_out(self, speed=60): + """ + The camera zooms out until self.stop_zoom() is called. + :return: response json + """ + return self._start_zoom('out', speed=speed) + + def stop_zoom(self): + """ + Stop zooming. + :return: response json + """ + data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}] + return self._execute_command('PtzCtrl', data) From 6506bf2cbcf3d1c2243361a935647231b7207798 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Fri, 6 Mar 2020 22:32:12 +0100 Subject: [PATCH 026/103] Make internal methods more generic --- api/zoom.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/api/zoom.py b/api/zoom.py index a3e2097..2ae766c 100644 --- a/api/zoom.py +++ b/api/zoom.py @@ -4,29 +4,32 @@ class ZoomAPIMixin: Note that the API does not allow zooming/focusing by absolute values rather that changing focus/zoom for a given time. """ - def _start_zoom(self, direction, speed=60): - op = 'ZoomInc' if direction == 'in' else 'ZoomDec' - data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": op, "speed": speed}}] + def _start_operation(self, operation, speed): + data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}] return self._execute_command('PtzCtrl', data) - def start_zoom_in(self, speed=60): + def _stop_zooming_or_focusing(self): + """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): """ - The camera zooms in until self.stop_zoom() is called. + The camera zooms in until self.stop_zooming() is called. :return: response json """ - return self._start_zoom('in', speed=speed) + return self._start_operation('ZoomInc', speed=speed) - def start_zoom_out(self, speed=60): + def start_zooming_out(self, speed=60): """ - The camera zooms out until self.stop_zoom() is called. + The camera zooms out until self.stop_zooming() is called. :return: response json """ - return self._start_zoom('out', speed=speed) + return self._start_operation('ZoomDec', speed=speed) - def stop_zoom(self): + def stop_zooming(self): """ Stop zooming. :return: response json """ - data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}] - return self._execute_command('PtzCtrl', data) + return self._stop_zooming_or_focusing() From eeb6189f7d97e044c6f94b3c3bbfcd90a25038f6 Mon Sep 17 00:00:00 2001 From: Max Ziermann Date: Fri, 6 Mar 2020 22:38:25 +0100 Subject: [PATCH 027/103] Add focusing API calls --- README.md | 2 +- api/zoom.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b5105d7..0d142a2 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,6 @@ SET: - [ ] User -> Manage User - [ ] Device -> HDD/SD Card - [x] Zoom -- [ ] Focus +- [x] Focus - [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) diff --git a/api/zoom.py b/api/zoom.py index 2ae766c..2bf0021 100644 --- a/api/zoom.py +++ b/api/zoom.py @@ -33,3 +33,24 @@ def stop_zooming(self): :return: response json """ return self._stop_zooming_or_focusing() + + def start_focusing_in(self, speed=32): + """ + 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): + """ + The camera focuses out until self.stop_focusing() is called. + :return: response json + """ + return self._start_operation('FocusDec', speed=speed) + + def stop_focusing(self): + """ + Stop focusing. + :return: response json + """ + return self._stop_zooming_or_focusing() From 9b5849988d14263a5e63856db885b60cb9a476ca Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Sun, 18 Oct 2020 08:50:16 -0500 Subject: [PATCH 028/103] Add PTZ function --- api/ptz.py | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 api/ptz.py diff --git a/api/ptz.py b/api/ptz.py new file mode 100644 index 0000000..42b9a29 --- /dev/null +++ b/api/ptz.py @@ -0,0 +1,120 @@ +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}}] + return self._execute_command('PtzCtrl', data) + + def _send_noparm_operation(self, operation): + 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'): + 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): + """ + 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'): + """ + Adds the current camera position to the specified preset. + :return: response json + """ + return self._send_set_preset('PtzPreset', enable=1, preset=preset, name=name) + + def remove_preset(self, preset=1, name='pos1'): + """ + Removes the specified preset + :return: response json + """ + return self._send_set_preset('PtzPreset', enable=0, preset=preset, name=name) + + def move_right(self, speed=32): + """ + Move the camera to the right + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Right', speed=speed) + + def move_right_up(self, speed=32): + """ + Move the camera to the right and up + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('RightUp', speed=speed) + + def move_right_down(self, speed=32): + """ + Move the camera to the right and down + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('RightDown', speed=speed) + + def move_left(self, speed=32): + """ + Move the camera to the left + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Left', speed=speed) + + def move_left_up(self, speed=32): + """ + Move the camera to the left and up + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('LeftUp', speed=speed) + + def move_left_down(self, speed=32): + """ + Move the camera to the left and down + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('LeftDown', speed=speed) + + def move_up(self, speed=32): + """ + Move the camera up. + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Up', speed=speed) + + def move_down(self, speed=32): + """ + Move the camera down. + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Down', speed=speed) + + def stop_ptz(self): + """ + Stops the cameras current action. + :return: response json + """ + return self._send_noparm_operation('Stop') + + def auto_movement(self, speed=32): + """ + Move the camera in a clockwise rotation. + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Auto', speed=speed) From aa14b601f5d6e7fa281af1211cae278b886a2df5 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Sun, 18 Oct 2020 11:17:05 -0500 Subject: [PATCH 029/103] Add requirement file, docs, updates to complete PTZ --- README.md | 2 ++ api/APIHandler.py | 20 ++++++++++++++++++-- api/ptz.py | 18 +++++++++--------- requirements.txt | 4 ++++ 4 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 0d142a2..fe338d6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-00 GET: - [X] Login +- [X] Logout - [ ] Display -> OSD - [ ] Recording -> Encode (Clear and Fluent Stream) - [ ] Recording -> Advance (Scheduling) @@ -73,6 +74,7 @@ SET: - [ ] User -> Add User - [ ] User -> Manage User - [ ] Device -> HDD/SD Card +- [x] PTZ - [x] Zoom - [x] Focus - [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) diff --git a/api/APIHandler.py b/api/APIHandler.py index eb59ec6..7fa04b7 100644 --- a/api/APIHandler.py +++ b/api/APIHandler.py @@ -5,6 +5,7 @@ from .network import NetworkAPIMixin from .system import SystemAPIMixin from .user import UserAPIMixin +from .ptz import PtzAPIMixin from resthandle import Request @@ -14,7 +15,8 @@ class APIHandler(SystemAPIMixin, DeviceAPIMixin, DisplayAPIMixin, RecordingAPIMixin, - ZoomAPIMixin): + ZoomAPIMixin, + PtzAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. @@ -68,7 +70,21 @@ def login(self) -> bool: except Exception as e: print("Error Login\n", e) raise - + + def logout(self) -> bool: + """ + Logout of the camera + :return: bool + """ + try: + data = [{"cmd": "Logout", "action": 0}] + ret = self._execute_command('Logout', data) + print(ret) + return True + except Exception as e: + print("Error Logout\n", e) + return False + def _execute_command(self, command, data, multi=False): """ Send a POST request to the IP camera with given data. diff --git a/api/ptz.py b/api/ptz.py index 42b9a29..463624e 100644 --- a/api/ptz.py +++ b/api/ptz.py @@ -40,7 +40,7 @@ def remove_preset(self, preset=1, name='pos1'): """ return self._send_set_preset('PtzPreset', enable=0, preset=preset, name=name) - def move_right(self, speed=32): + def move_right(self, speed=25): """ Move the camera to the right The camera moves self.stop_ptz() is called. @@ -48,7 +48,7 @@ def move_right(self, speed=32): """ return self._send_operation('Right', speed=speed) - def move_right_up(self, speed=32): + def move_right_up(self, speed=25): """ Move the camera to the right and up The camera moves self.stop_ptz() is called. @@ -56,7 +56,7 @@ def move_right_up(self, speed=32): """ return self._send_operation('RightUp', speed=speed) - def move_right_down(self, speed=32): + def move_right_down(self, speed=25): """ Move the camera to the right and down The camera moves self.stop_ptz() is called. @@ -64,7 +64,7 @@ def move_right_down(self, speed=32): """ return self._send_operation('RightDown', speed=speed) - def move_left(self, speed=32): + def move_left(self, speed=25): """ Move the camera to the left The camera moves self.stop_ptz() is called. @@ -72,7 +72,7 @@ def move_left(self, speed=32): """ return self._send_operation('Left', speed=speed) - def move_left_up(self, speed=32): + def move_left_up(self, speed=25): """ Move the camera to the left and up The camera moves self.stop_ptz() is called. @@ -80,7 +80,7 @@ def move_left_up(self, speed=32): """ return self._send_operation('LeftUp', speed=speed) - def move_left_down(self, speed=32): + def move_left_down(self, speed=25): """ Move the camera to the left and down The camera moves self.stop_ptz() is called. @@ -88,7 +88,7 @@ def move_left_down(self, speed=32): """ return self._send_operation('LeftDown', speed=speed) - def move_up(self, speed=32): + def move_up(self, speed=25): """ Move the camera up. The camera moves self.stop_ptz() is called. @@ -96,7 +96,7 @@ def move_up(self, speed=32): """ return self._send_operation('Up', speed=speed) - def move_down(self, speed=32): + def move_down(self, speed=25): """ Move the camera down. The camera moves self.stop_ptz() is called. @@ -111,7 +111,7 @@ def stop_ptz(self): """ return self._send_noparm_operation('Stop') - def auto_movement(self, speed=32): + def auto_movement(self, speed=25): """ Move the camera in a clockwise rotation. The camera moves self.stop_ptz() is called. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..30b468d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +opencv-python +numpy +socks \ No newline at end of file From 9218ff15497856ca9e2a7fe72248ddd56faf5f60 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Sun, 25 Oct 2020 07:24:43 -0500 Subject: [PATCH 030/103] Add pieces to allow PyPi package publish --- api/__init__.py | 5 ++- make-and-publish-package.sh | 3 ++ setup.py | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100755 make-and-publish-package.sh create mode 100644 setup.py diff --git a/api/__init__.py b/api/__init__.py index b8cf11e..a7db0e9 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1 +1,4 @@ -from .APIHandler import APIHandler \ No newline at end of file +from .APIHandler import APIHandler + +__version__ = "0.0.1" +VERSION = __version__ diff --git a/make-and-publish-package.sh b/make-and-publish-package.sh new file mode 100755 index 0000000..43cca45 --- /dev/null +++ b/make-and-publish-package.sh @@ -0,0 +1,3 @@ +rm -fr dist +python setup.py sdist +twine upload dist/* \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..614ea1c --- /dev/null +++ b/setup.py @@ -0,0 +1,70 @@ +#!/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() + + +def read(*parts): + with codecs.open(os.path.join(here, *parts), 'r') as fp: + return fp.read() + + +def find_version(*file_paths): + version_file = read(*file_paths) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) + if version_match: + return version_match.group(1) + 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' + ] + ) From bef4f8d5fd36d902bc5184f9f9a76a033d3f886c Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Mon, 26 Oct 2020 16:39:13 +0200 Subject: [PATCH 031/103] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index fe338d6..6d88ba4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ You can get the Restful API calls by looking through the HTTP Requests made the Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. +### Using the library as a Python Module + +Install the package via Pip + + pip install reolink-api==0.0.1 + ### Styling and Standards This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) From c8eb387cd1a11e6dd6aee2a9d517638f8d53cb44 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Tue, 27 Oct 2020 18:53:09 -0500 Subject: [PATCH 032/103] Update README (implemented methods), add more getters --- README.md | 48 ++++++++--------- api/network.py | 63 +++++++++++++++++++++++ api/system.py | 9 ++++ examples/response/GetDSTInfo.json | 35 +++++++++++++ examples/response/GetNetworkAdvanced.json | 42 +++++++++++++++ examples/response/GetNetworkDDNS.json | 15 ++++++ examples/response/GetNetworkEmail.json | 25 +++++++++ examples/response/GetNetworkFtp.json | 24 +++++++++ examples/response/GetNetworkGeneral.json | 19 +++++++ examples/response/GetNetworkNTP.json | 14 +++++ examples/response/GetNetworkPush.json | 14 +++++ 11 files changed, 284 insertions(+), 24 deletions(-) create mode 100644 examples/response/GetDSTInfo.json create mode 100644 examples/response/GetNetworkAdvanced.json create mode 100644 examples/response/GetNetworkDDNS.json create mode 100644 examples/response/GetNetworkEmail.json create mode 100644 examples/response/GetNetworkFtp.json create mode 100644 examples/response/GetNetworkGeneral.json create mode 100644 examples/response/GetNetworkNTP.json create mode 100644 examples/response/GetNetworkPush.json diff --git a/README.md b/README.md index 6d88ba4..97347f5 100644 --- a/README.md +++ b/README.md @@ -32,36 +32,36 @@ This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-00 GET: - [X] Login - [X] Logout -- [ ] Display -> OSD -- [ ] Recording -> Encode (Clear and Fluent Stream) -- [ ] Recording -> Advance (Scheduling) +- [X] Display -> OSD +- [X] Recording -> Encode (Clear and Fluent Stream) +- [X] Recording -> Advance (Scheduling) - [X] Network -> General - [X] Network -> Advanced -- [ ] Network -> DDNS -- [ ] Network -> NTP -- [ ] Network -> E-mail -- [ ] Network -> FTP -- [ ] Network -> Push +- [X] Network -> DDNS +- [X] Network -> NTP +- [X] Network -> E-mail +- [X] Network -> FTP +- [X] Network -> Push - [X] Network -> WIFI - [ ] Alarm -> Motion - [X] System -> General -- [ ] System -> DST -- [ ] System -> Information +- [X] System -> DST +- [X] System -> Information - [ ] System -> Maintenance -- [ ] System -> Performance +- [X] System -> Performance - [ ] System -> Reboot -- [ ] User -> Online User -- [ ] User -> Add User -- [ ] User -> Manage User -- [ ] Device -> HDD/SD Card +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card - [ ] Zoom - [ ] Focus -- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) +- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) -- [ ] Image Data -> "Snap" Frame from Video Stream +- [X] Image Data -> "Snap" Frame from Video Stream SET: -- [ ] Display -> OSD +- [X] Display -> OSD - [ ] Recording -> Encode (Clear and Fluent Stream) - [ ] Recording -> Advance (Scheduling) - [X] Network -> General @@ -73,15 +73,15 @@ SET: - [ ] Network -> Push - [X] Network -> WIFI - [ ] Alarm -> Motion -- [X] System -> General +- [ ] System -> General - [ ] System -> DST - [X] System -> Reboot -- [ ] User -> Online User -- [ ] User -> Add User -- [ ] User -> Manage User -- [ ] Device -> HDD/SD Card +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card (Format) - [x] PTZ - [x] Zoom - [x] Focus -- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) +- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) diff --git a/api/network.py b/api/network.py index 8e1ecc5..39af7b8 100644 --- a/api/network.py +++ b/api/network.py @@ -36,6 +36,7 @@ def set_wifi(self, ssid, password) -> object: def get_net_ports(self) -> object: """ Get network ports + See examples/response/GetNetworkAdvanced.json for example response data. :return: response json """ body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, @@ -50,3 +51,65 @@ def get_wifi(self): def scan_wifi(self): body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] return self._execute_command('ScanWifi', body) + + def get_network_general(self) -> object: + """ + Get the camera information + See examples/response/GetNetworkGeneral.json for example response data. + :return: response json + """ + body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}] + return self._execute_command('GetLocalLink', body) + + def get_network_ddns(self) -> object: + """ + Get the camera DDNS network information + See examples/response/GetNetworkDDNS.json for example response data. + :return: response json + """ + body = [{"cmd": "GetDdns", "action": 0, "param": {}}] + return self._execute_command('GetDdns', body) + + def get_network_ntp(self) -> object: + """ + Get the camera NTP network information + See examples/response/GetNetworkNTP.json for example response data. + :return: response json + """ + body = [{"cmd": "GetNtp", "action": 0, "param": {}}] + return self._execute_command('GetNtp', body) + + def get_network_email(self) -> object: + """ + Get the camera email network information + See examples/response/GetNetworkEmail.json for example response data. + :return: response json + """ + body = [{"cmd": "GetEmail", "action": 0, "param": {}}] + return self._execute_command('GetEmail', body) + + def get_network_ftp(self) -> object: + """ + Get the camera FTP network information + See examples/response/GetNetworkFtp.json for example response data. + :return: response json + """ + body = [{"cmd": "GetFtp", "action": 0, "param": {}}] + return self._execute_command('GetFtp', body) + + def get_network_push(self) -> object: + """ + Get the camera push network information + See examples/response/GetNetworkPush.json for example response data. + :return: response json + """ + body = [{"cmd": "GetPush", "action": 0, "param": {}}] + return self._execute_command('GetPush', body) + + def get_network_status(self) -> object: + """ + Get the camera status network information + See examples/response/GetNetworkGeneral.json for example response data. + :return: response json + """ + return self.get_network_general() diff --git a/api/system.py b/api/system.py index 244b849..0eadc6a 100644 --- a/api/system.py +++ b/api/system.py @@ -30,3 +30,12 @@ def reboot_camera(self) -> object: """ body = [{"cmd": "Reboot", "action": 0, "param": {}}] return self._execute_command('Reboot', body) + + def get_dst(self) -> object: + """ + 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 diff --git a/examples/response/GetDSTInfo.json b/examples/response/GetDSTInfo.json new file mode 100644 index 0000000..5895d9e --- /dev/null +++ b/examples/response/GetDSTInfo.json @@ -0,0 +1,35 @@ +[ + { + "cmd": "GetTime", + "code": 0, + "value": { + "Dst": { + "enable": 1, + "endHour": 2, + "endMin": 0, + "endMon": 11, + "endSec": 0, + "endWeek": 1, + "endWeekday": 0, + "offset": 1, + "startHour": 2, + "startMin": 0, + "startMon": 3, + "startSec": 0, + "startWeek": 1, + "startWeekday": 0 + }, + "Time": { + "day": 27, + "hour": 18, + "hourFmt": 0, + "min": 50, + "mon": 10, + "sec": 46, + "timeFmt": "MM/DD/YYYY", + "timeZone": 21600, + "year": 2020 + } + } + } +] diff --git a/examples/response/GetNetworkAdvanced.json b/examples/response/GetNetworkAdvanced.json new file mode 100644 index 0000000..25c729e --- /dev/null +++ b/examples/response/GetNetworkAdvanced.json @@ -0,0 +1,42 @@ +[ + { + "cmd": "GetNetPort", + "code": 0, + "initial": { + "NetPort": { + "httpPort": 80, + "httpsPort": 443, + "mediaPort": 9000, + "onvifPort": 8000, + "rtmpPort": 1935, + "rtspPort": 554 + } + }, + "range": { + "NetPort": { + "httpPort": { "max": 65535, "min": 1 }, + "httpsPort": { "max": 65535, "min": 1 }, + "mediaPort": { "max": 65535, "min": 1 }, + "onvifPort": { "max": 65535, "min": 1 }, + "rtmpPort": { "max": 65535, "min": 1 }, + "rtspPort": { "max": 65535, "min": 1 } + } + }, + "value": { + "NetPort": { + "httpPort": 80, + "httpsPort": 443, + "mediaPort": 9000, + "onvifPort": 8000, + "rtmpPort": 1935, + "rtspPort": 554 + } + } + }, + { "cmd": "GetUpnp", "code": 0, "value": { "Upnp": { "enable": 0 } } }, + { + "cmd": "GetP2p", + "code": 0, + "value": { "P2p": { "enable": 0, "uid": "99999999999999" } } + } +] diff --git a/examples/response/GetNetworkDDNS.json b/examples/response/GetNetworkDDNS.json new file mode 100644 index 0000000..a79127a --- /dev/null +++ b/examples/response/GetNetworkDDNS.json @@ -0,0 +1,15 @@ +[ + { + "cmd": "GetDdns", + "code": 0, + "value": { + "Ddns": { + "domain": "", + "enable": 0, + "password": "", + "type": "no-ip", + "userName": "" + } + } + } +] diff --git a/examples/response/GetNetworkEmail.json b/examples/response/GetNetworkEmail.json new file mode 100644 index 0000000..9595a2a --- /dev/null +++ b/examples/response/GetNetworkEmail.json @@ -0,0 +1,25 @@ +[ + { + "cmd": "GetEmail", + "code": 0, + "value": { + "Email": { + "addr1": "", + "addr2": "", + "addr3": "", + "attachment": "picture", + "interval": "5 Minutes", + "nickName": "", + "password": "", + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "smtpPort": 465, + "smtpServer": "smtp.gmail.com", + "ssl": 1, + "userName": "" + } + } + } +] diff --git a/examples/response/GetNetworkFtp.json b/examples/response/GetNetworkFtp.json new file mode 100644 index 0000000..8ba4622 --- /dev/null +++ b/examples/response/GetNetworkFtp.json @@ -0,0 +1,24 @@ +[ + { + "cmd": "GetFtp", + "code": 0, + "value": { + "Ftp": { + "anonymous": 0, + "interval": 30, + "maxSize": 100, + "mode": 0, + "password": "", + "port": 21, + "remoteDir": "", + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "server": "", + "streamType": 0, + "userName": "" + } + } + } +] diff --git a/examples/response/GetNetworkGeneral.json b/examples/response/GetNetworkGeneral.json new file mode 100644 index 0000000..958bbdd --- /dev/null +++ b/examples/response/GetNetworkGeneral.json @@ -0,0 +1,19 @@ +[ + { + "cmd": "GetLocalLink", + "code": 0, + "value": { + "LocalLink": { + "activeLink": "LAN", + "dns": { "auto": 1, "dns1": "192.168.255.4", "dns2": "192.168.255.4" }, + "mac": "EC:71:DB:AA:59:CF", + "static": { + "gateway": "192.168.255.1", + "ip": "192.168.255.58", + "mask": "255.255.255.0" + }, + "type": "DHCP" + } + } + } +] diff --git a/examples/response/GetNetworkNTP.json b/examples/response/GetNetworkNTP.json new file mode 100644 index 0000000..7293fed --- /dev/null +++ b/examples/response/GetNetworkNTP.json @@ -0,0 +1,14 @@ +[ + { + "cmd": "GetNtp", + "code": 0, + "value": { + "Ntp": { + "enable": 1, + "interval": 1440, + "port": 123, + "server": "ntp.moos.xyz" + } + } + } +] diff --git a/examples/response/GetNetworkPush.json b/examples/response/GetNetworkPush.json new file mode 100644 index 0000000..399207f --- /dev/null +++ b/examples/response/GetNetworkPush.json @@ -0,0 +1,14 @@ +[ + { + "cmd": "GetPush", + "code": 0, + "value": { + "Push": { + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + } + } + } + } +] From b4f95d7a0bc35990fada0f0734934da6d4bb3716 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Wed, 28 Oct 2020 06:07:02 -0500 Subject: [PATCH 033/103] Add Alarm getters --- README.md | 2 +- api/APIHandler.py | 4 +- api/__init__.py | 2 +- api/alarm.py | 11 ++ examples/response/GetAlarmMotion.json | 150 ++++++++++++++++++++++++++ setup.py | 3 +- 6 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 api/alarm.py create mode 100644 examples/response/GetAlarmMotion.json diff --git a/README.md b/README.md index 97347f5..f91b5ee 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ GET: - [X] Network -> FTP - [X] Network -> Push - [X] Network -> WIFI -- [ ] Alarm -> Motion +- [X] Alarm -> Motion - [X] System -> General - [X] System -> DST - [X] System -> Information diff --git a/api/APIHandler.py b/api/APIHandler.py index 7fa04b7..a62846a 100644 --- a/api/APIHandler.py +++ b/api/APIHandler.py @@ -6,6 +6,7 @@ from .system import SystemAPIMixin from .user import UserAPIMixin from .ptz import PtzAPIMixin +from .alarm import AlarmAPIMixin from resthandle import Request @@ -16,7 +17,8 @@ class APIHandler(SystemAPIMixin, DisplayAPIMixin, RecordingAPIMixin, ZoomAPIMixin, - PtzAPIMixin): + PtzAPIMixin, + AlarmAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. diff --git a/api/__init__.py b/api/__init__.py index a7db0e9..06bd9be 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,4 +1,4 @@ from .APIHandler import APIHandler -__version__ = "0.0.1" +__version__ = "0.0.2" VERSION = __version__ diff --git a/api/alarm.py b/api/alarm.py new file mode 100644 index 0000000..2f48efb --- /dev/null +++ b/api/alarm.py @@ -0,0 +1,11 @@ +class AlarmAPIMixin: + """API calls for getting device alarm information.""" + + def get_alarm_motion(self) -> object: + """ + Gets the device alarm motion + See examples/response/GetAlarmMotion.json for example response data. + :return: response json + """ + body = [{"cmd": "GetAlarm", "action": 1, "param": {"Alarm": {"channel": 0, "type": "md"}}}] + return self._execute_command('GetAlarm', body) diff --git a/examples/response/GetAlarmMotion.json b/examples/response/GetAlarmMotion.json new file mode 100644 index 0000000..56be0cc --- /dev/null +++ b/examples/response/GetAlarmMotion.json @@ -0,0 +1,150 @@ +[ + { + "cmd": "GetAlarm", + "code": 0, + "initial": { + "Alarm": { + "action": { "mail": 1, "push": 1, "recChannel": [0] }, + "channel": 0, + "enable": 1, + "schedule": { + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "scope": { + "cols": 80, + "rows": 45, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "sens": [ + { + "beginHour": 0, + "beginMin": 0, + "endHour": 6, + "endMin": 0, + "sensitivity": 10 + }, + { + "beginHour": 6, + "beginMin": 0, + "endHour": 12, + "endMin": 0, + "sensitivity": 10 + }, + { + "beginHour": 12, + "beginMin": 0, + "endHour": 18, + "endMin": 0, + "sensitivity": 10 + }, + { + "beginHour": 18, + "beginMin": 0, + "endHour": 23, + "endMin": 59, + "sensitivity": 10 + } + ], + "type": "md" + } + }, + "range": { + "Alarm": { + "action": { "mail": "boolean", "push": "boolean", "recChannel": [0] }, + "channel": 0, + "enable": "boolean", + "schedule": { "table": { "maxLen": 168, "minLen": 168 } }, + "scope": { + "cols": { "max": 80, "min": 80 }, + "rows": { "max": 45, "min": 45 }, + "table": { "maxLen": 8159 } + }, + "sens": [ + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 0, + "sensitivity": { "max": 50, "min": 1 } + }, + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 1, + "sensitivity": { "max": 50, "min": 1 } + }, + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 2, + "sensitivity": { "max": 50, "min": 1 } + }, + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 3, + "sensitivity": { "max": 50, "min": 1 } + } + ], + "type": "md" + } + }, + "value": { + "Alarm": { + "action": { "mail": 1, "push": 1, "recChannel": [0] }, + "channel": 0, + "enable": 1, + "schedule": { + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "scope": { + "cols": 80, + "rows": 45, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "sens": [ + { + "beginHour": 0, + "beginMin": 0, + "endHour": 6, + "endMin": 0, + "id": 0, + "sensitivity": 10 + }, + { + "beginHour": 6, + "beginMin": 0, + "endHour": 12, + "endMin": 0, + "id": 1, + "sensitivity": 10 + }, + { + "beginHour": 12, + "beginMin": 0, + "endHour": 18, + "endMin": 0, + "id": 2, + "sensitivity": 10 + }, + { + "beginHour": 18, + "beginMin": 0, + "endHour": 23, + "endMin": 59, + "id": 3, + "sensitivity": 10 + } + ], + "type": "md" + } + } + } +] diff --git a/setup.py b/setup.py index 614ea1c..70c8068 100644 --- a/setup.py +++ b/setup.py @@ -65,6 +65,7 @@ def find_version(*file_paths): 'api.recording', 'api.system', 'api.user', - 'api.zoom' + 'api.zoom', + 'api.alarm' ] ) From ad08a8ce71384d65c5c7863bb11a503a6b449767 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Wed, 28 Oct 2020 20:52:09 -0500 Subject: [PATCH 034/103] Rewrite get_snap to use requests lib as the old method did not work. --- api/recording.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/api/recording.py b/api/recording.py index c204708..7ef1505 100644 --- a/api/recording.py +++ b/api/recording.py @@ -1,12 +1,10 @@ -import io +import requests import random import string -from urllib import request - +from urllib import parse +from io import BytesIO from PIL import Image - from RtspClient import RtspClient -from resthandle import Request class RecordingAPIMixin: @@ -49,19 +47,19 @@ def get_snap(self, timeout: int = 3) -> Image or None: :param timeout: Request timeout to camera in seconds :return: Image or None """ - randomstr = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) - snap = self.url + "?cmd=Snap&channel=0&rs=" \ - + randomstr \ - + "&user=" + self.username \ - + "&password=" + self.password + 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: - req = request.Request(snap) - req.set_proxy(Request.proxies, 'http') - reader = request.urlopen(req, timeout) - if reader.status == 200: - b = bytearray(reader.read()) - return Image.open(io.BytesIO(b)) - print("Could not retrieve data from camera successfully. Status:", reader.status) + response = requests.get(self.url, 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: From 00c00ecf04315d18d10508c07c7528958e221180 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Thu, 29 Oct 2020 07:32:45 -0500 Subject: [PATCH 035/103] Add proxy support back to recording snap() --- api/recording.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/recording.py b/api/recording.py index 7ef1505..ff8f5e7 100644 --- a/api/recording.py +++ b/api/recording.py @@ -41,10 +41,11 @@ def open_video_stream(self, profile: str = "main") -> Image: proxies={"host": "127.0.0.1", "port": 8000}) as rtsp_client: rtsp_client.preview() - def get_snap(self, timeout: int = 3) -> Image or None: + 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 = {} @@ -56,7 +57,7 @@ def get_snap(self, timeout: int = 3) -> Image or None: parms = parse.urlencode(data).encode("utf-8") try: - response = requests.get(self.url, params=parms, timeout=timeout) + 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) From 6bb9900b25446b8cfc18b16e34ec1ef50c0fdaca Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Thu, 29 Oct 2020 16:45:08 -0500 Subject: [PATCH 036/103] Refactor Rtsp client --- RtspClient.py | 88 ++++++++++++++---------------------------------- api/recording.py | 15 ++++----- 2 files changed, 32 insertions(+), 71 deletions(-) diff --git a/RtspClient.py b/RtspClient.py index b3cb3fb..06f2e04 100644 --- a/RtspClient.py +++ b/RtspClient.py @@ -1,85 +1,47 @@ -import socket - +import os import cv2 -import numpy -import socks class RtspClient: - def __init__(self, ip, username, password, port=554, profile="main", **kwargs): + def __init__(self, ip, username, password, port=554, profile="main", use_udp=True, **kwargs): """ - :param ip: - :param username: - :param password: - :param port: rtsp port + :param ip: Camera IP + :param username: Camera Username + :param password: Camera User Password + :param port: RTSP port :param profile: "main" or "sub" + :param use_upd: True to use UDP, False to use TCP :param proxies: {"host": "localhost", "port": 8000} """ + capture_options = 'rtsp_transport;' self.ip = ip self.username = username self.password = password self.port = port - self.sockt = None - self.url = "rtsp://" + self.username + ":" + self.password + "@" + self.ip + ":" + str( - self.port) + "//h264Preview_01_" + profile 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' - def __enter__(self): - self.sockt = self.connect() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.sockt.close() - - def connect(self) -> socket: - try: - sockt = socks.socksocket(socket.AF_INET, socket.SOCK_STREAM) - sockt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if self.proxy is not None: - sockt.set_proxy(socks.SOCKS5, self.proxy["host"], self.proxy["port"]) - sockt.connect((self.ip, self.port)) - return sockt - except Exception as e: - print(e) - - def get_frame(self) -> bytearray: - try: - self.sockt.send(str.encode(self.url)) - data = b'' - while True: - try: - r = self.sockt.recv(90456) - if len(r) == 0: - break - a = r.find(b'END!') - if a != -1: - data += r[:a] - break - data += r - except Exception as e: - print(e) - continue - nparr = numpy.fromstring(data, numpy.uint8) - frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - return frame - except Exception as e: - print(e) + os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options def preview(self): """ Blocking function. Opens OpenCV window to display stream. """ - self.connect() - win_name = 'RTSP' - cv2.namedWindow(win_name, cv2.WINDOW_AUTOSIZE) - cv2.moveWindow(win_name, 20, 20) + win_name = self.ip + cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) + ret, frame = cap.read() + + while ret: + cv2.imshow(win_name, frame) - while True: - cv2.imshow(win_name, self.get_frame()) - # if self._latest is not None: - # cv2.imshow(win_name,self._latest) - if cv2.waitKey(25) & 0xFF == ord('q'): + ret, frame = cap.read() + if (cv2.waitKey(1) & 0xFF == ord('q')): break - cv2.waitKey() + + cap.release() cv2.destroyAllWindows() - cv2.waitKey() diff --git a/api/recording.py b/api/recording.py index ff8f5e7..d221fa5 100644 --- a/api/recording.py +++ b/api/recording.py @@ -30,16 +30,15 @@ def get_recording_advanced(self) -> object: ########### # RTSP Stream ########### - def open_video_stream(self, profile: str = "main") -> Image: + def open_video_stream(self, profile: str = "main", proxies=None) -> None: """ - profile is "main" or "sub" - https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player - :param profile: - :return: + 'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player' + :param profile: profile is "main" or "sub" + :param proxies: Default is none, example: {"host": "localhost", "port": 8000} """ - with RtspClient(ip=self.ip, username=self.username, password=self.password, - proxies={"host": "127.0.0.1", "port": 8000}) as rtsp_client: - rtsp_client.preview() + rtsp_client = RtspClient( + ip=self.ip, username=self.username, password=self.password, proxies=proxies) + rtsp_client.preview() def get_snap(self, timeout: int = 3, proxies=None) -> Image or None: """ From 02b983788853822ef4fd3e5059ba2d290cfd722a Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Fri, 30 Oct 2020 05:39:34 -0500 Subject: [PATCH 037/103] Add Recording Encode setter --- README.md | 2 +- api/APIHandler.py | 4 ++-- api/recording.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f91b5ee..fcf2dae 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ GET: SET: - [X] Display -> OSD -- [ ] Recording -> Encode (Clear and Fluent Stream) +- [X] Recording -> Encode (Clear and Fluent Stream) - [ ] Recording -> Advance (Scheduling) - [X] Network -> General - [X] Network -> Advanced diff --git a/api/APIHandler.py b/api/APIHandler.py index a62846a..59df805 100644 --- a/api/APIHandler.py +++ b/api/APIHandler.py @@ -80,8 +80,8 @@ def logout(self) -> bool: """ try: data = [{"cmd": "Logout", "action": 0}] - ret = self._execute_command('Logout', data) - print(ret) + self._execute_command('Logout', data) + # print(ret) return True except Exception as e: print("Error Logout\n", e) diff --git a/api/recording.py b/api/recording.py index d221fa5..259e56a 100644 --- a/api/recording.py +++ b/api/recording.py @@ -27,6 +27,48 @@ def get_recording_advanced(self) -> object: 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 ########### From dd86eaca2e3851de08b1a267cc79ebdb1832e280 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Fri, 30 Oct 2020 06:00:03 -0500 Subject: [PATCH 038/103] Add Advanced Image settings --- README.md | 2 +- api/recording.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fcf2dae..594eb07 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,4 @@ SET: - [x] Zoom - [x] Focus - [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) -- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) +- [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) diff --git a/api/recording.py b/api/recording.py index 259e56a..5d1d7d6 100644 --- a/api/recording.py +++ b/api/recording.py @@ -69,6 +69,68 @@ def set_recording_encoding(self, }}] return self._execute_command('SetEnc', body) + def set_advanced_imaging(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: + """ + Sets the advanced camera settings. + :param anti_flicker: string + :param exposure: string + :param gain_min: int + :param gain_max: string + :param shutter_min: int + :param shutter_max: int + :param blue_gain: int + :param red_gain: int + :param white_balance: string + :param day_night: string + :param back_light: string + :param blc: int + :param drc: int + :param rotation: int + :param mirroring: int + :param nr3d: int + :return: response + """ + body = [{ + "cmd": "SetIsp", + "action": 0, + "param": { + "Isp": { + "channel": 0, + "antiFlicker": anti_flicker, + "exposure": exposure, + "gain": {"min": gain_min, "max": gain_max}, + "shutter": {"min": shutter_min, "max": shutter_max}, + "blueGain": blue_gain, + "redGain": red_gain, + "whiteBalance": white_balance, + "dayNight": day_night, + "backLight": back_light, + "blc": blc, + "drc": drc, + "rotation": rotation, + "mirroring": mirroring, + "nr3d": nr3d + } + } + }] + return self._execute_command('SetIsp', body) + ########### # RTSP Stream ########### From e1eabf535dc0a56b67a675b497595c4dbad851cb Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Fri, 30 Oct 2020 06:06:25 -0500 Subject: [PATCH 039/103] Refactor image settings from recording to new mixin --- api/APIHandler.py | 4 ++- api/image.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++ api/recording.py | 62 -------------------------------------------- setup.py | 3 ++- 4 files changed, 70 insertions(+), 64 deletions(-) create mode 100644 api/image.py diff --git a/api/APIHandler.py b/api/APIHandler.py index 59df805..a4a6f07 100644 --- a/api/APIHandler.py +++ b/api/APIHandler.py @@ -7,6 +7,7 @@ from .user import UserAPIMixin from .ptz import PtzAPIMixin from .alarm import AlarmAPIMixin +from .image import ImageAPIMixin from resthandle import Request @@ -18,7 +19,8 @@ class APIHandler(SystemAPIMixin, RecordingAPIMixin, ZoomAPIMixin, PtzAPIMixin, - AlarmAPIMixin): + AlarmAPIMixin, + ImageAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. diff --git a/api/image.py b/api/image.py new file mode 100644 index 0000000..4bcfcca --- /dev/null +++ b/api/image.py @@ -0,0 +1,65 @@ + +class ImageAPIMixin: + """API calls for image settings.""" + + def set_advanced_imaging(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: + """ + Sets the advanced camera settings. + :param anti_flicker: string + :param exposure: string + :param gain_min: int + :param gain_max: string + :param shutter_min: int + :param shutter_max: int + :param blue_gain: int + :param red_gain: int + :param white_balance: string + :param day_night: string + :param back_light: string + :param blc: int + :param drc: int + :param rotation: int + :param mirroring: int + :param nr3d: int + :return: response + """ + body = [{ + "cmd": "SetIsp", + "action": 0, + "param": { + "Isp": { + "channel": 0, + "antiFlicker": anti_flicker, + "exposure": exposure, + "gain": {"min": gain_min, "max": gain_max}, + "shutter": {"min": shutter_min, "max": shutter_max}, + "blueGain": blue_gain, + "redGain": red_gain, + "whiteBalance": white_balance, + "dayNight": day_night, + "backLight": back_light, + "blc": blc, + "drc": drc, + "rotation": rotation, + "mirroring": mirroring, + "nr3d": nr3d + } + } + }] + return self._execute_command('SetIsp', body) diff --git a/api/recording.py b/api/recording.py index 5d1d7d6..259e56a 100644 --- a/api/recording.py +++ b/api/recording.py @@ -69,68 +69,6 @@ def set_recording_encoding(self, }}] return self._execute_command('SetEnc', body) - def set_advanced_imaging(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: - """ - Sets the advanced camera settings. - :param anti_flicker: string - :param exposure: string - :param gain_min: int - :param gain_max: string - :param shutter_min: int - :param shutter_max: int - :param blue_gain: int - :param red_gain: int - :param white_balance: string - :param day_night: string - :param back_light: string - :param blc: int - :param drc: int - :param rotation: int - :param mirroring: int - :param nr3d: int - :return: response - """ - body = [{ - "cmd": "SetIsp", - "action": 0, - "param": { - "Isp": { - "channel": 0, - "antiFlicker": anti_flicker, - "exposure": exposure, - "gain": {"min": gain_min, "max": gain_max}, - "shutter": {"min": shutter_min, "max": shutter_max}, - "blueGain": blue_gain, - "redGain": red_gain, - "whiteBalance": white_balance, - "dayNight": day_night, - "backLight": back_light, - "blc": blc, - "drc": drc, - "rotation": rotation, - "mirroring": mirroring, - "nr3d": nr3d - } - } - }] - return self._execute_command('SetIsp', body) - ########### # RTSP Stream ########### diff --git a/setup.py b/setup.py index 70c8068..98eba70 100644 --- a/setup.py +++ b/setup.py @@ -66,6 +66,7 @@ def find_version(*file_paths): 'api.system', 'api.user', 'api.zoom', - 'api.alarm' + 'api.alarm', + 'api.image' ] ) From 0c3475aa00a096fa93bcbb53895128be47c1a61b Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Fri, 30 Oct 2020 07:27:33 -0500 Subject: [PATCH 040/103] Add Image settings setter --- README.md | 2 +- api/image.py | 70 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 594eb07..8cca35b 100644 --- a/README.md +++ b/README.md @@ -83,5 +83,5 @@ SET: - [x] PTZ - [x] Zoom - [x] Focus -- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) +- [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) - [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) diff --git a/api/image.py b/api/image.py index 4bcfcca..6cdb823 100644 --- a/api/image.py +++ b/api/image.py @@ -2,25 +2,26 @@ class ImageAPIMixin: """API calls for image settings.""" - def set_advanced_imaging(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: + 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: """ Sets the advanced camera settings. + :param anti_flicker: string :param exposure: string :param gain_min: int @@ -63,3 +64,38 @@ def set_advanced_imaging(self, } }] return self._execute_command('SetIsp', body) + + def set_image_settings(self, + brightness=128, + contrast=62, + hue=1, + saturation=125, + sharpness=128) -> object: + """ + Sets the camera image settings. + + :param brightness: int + :param contrast: string + :param hue: int + :param saturation: int + :param sharpness: int + :return: response + """ + body = [ + { + "cmd": "SetImage", + "action": 0, + "param": { + "Image": { + "bright": brightness, + "channel": 0, + "contrast": contrast, + "hue": hue, + "saturation": saturation, + "sharpen": sharpness + } + } + } + ] + + return self._execute_command('SetImage', body) From feae1e91a9d7e961a740a82c2447f6ecf7daf034 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Tue, 3 Nov 2020 21:16:06 +0200 Subject: [PATCH 041/103] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8cca35b..cd90018 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ ## ReolinkCameraAPI +### Join us on Discord + + https://discord.gg/8z3fdAmZJP + ### Purpose This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS ) From 25ce2cbb555e046efa4b6aaa76a35add02bafff2 Mon Sep 17 00:00:00 2001 From: Alano Date: Fri, 4 Dec 2020 07:47:55 +0200 Subject: [PATCH 042/103] updated version: v0.0.4 --- .gitignore | 2 ++ .idea/.gitignore | 2 -- .idea/ReolinkCameraAPI.iml | 11 ----------- .idea/codeStyles/codeStyleConfig.xml | 5 ----- .idea/inspectionProfiles/profiles_settings.xml | 6 ------ .idea/misc.xml | 7 ------- .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ api/__init__.py | 2 +- 9 files changed, 3 insertions(+), 46 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/ReolinkCameraAPI.iml delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index a65d046..368d535 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ docs/_build/ # PyBuilder target/ + +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 5c98b42..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Default ignored files -/workspace.xml \ No newline at end of file diff --git a/.idea/ReolinkCameraAPI.iml b/.idea/ReolinkCameraAPI.iml deleted file mode 100644 index 6711606..0000000 --- a/.idea/ReolinkCameraAPI.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 3999087..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 8125795..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py index 06bd9be..916ada9 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,4 +1,4 @@ from .APIHandler import APIHandler -__version__ = "0.0.2" +__version__ = "0.0.4" VERSION = __version__ From 5d03c62b39bd6b757249f923e47cbf34599a5992 Mon Sep 17 00:00:00 2001 From: Alano Date: Fri, 4 Dec 2020 22:27:50 +0200 Subject: [PATCH 043/103] adding blocking and non-blocking rtsp stream This might fix the stream errors in issue #25 Also refactored the code a bit since we do not want to display a window from within the API (offload to the person implementing it). --- RtspClient.py | 92 +++++++++++++++++++++++++++++++------ api/__init__.py | 2 +- api/recording.py | 36 ++++++++------- examples/streaming_video.py | 82 +++++++++++++++++++++++++++++++++ util.py | 11 +++++ 5 files changed, 191 insertions(+), 32 deletions(-) create mode 100644 examples/streaming_video.py create mode 100644 util.py diff --git a/RtspClient.py b/RtspClient.py index 06f2e04..6cf37c1 100644 --- a/RtspClient.py +++ b/RtspClient.py @@ -1,11 +1,22 @@ import os +from threading import ThreadError + import cv2 +from util import threaded + class RtspClient: + """ + 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, **kwargs): + def __init__(self, ip, username, password, port=554, profile="main", use_udp=True, callback=None, **kwargs): """ + RTSP client is used to retrieve frames from the camera in a stream :param ip: Camera IP :param username: Camera Username @@ -15,6 +26,10 @@ def __init__(self, ip, username, password, port=554, profile="main", use_udp=Tru :param use_upd: True to use UDP, False to use TCP :param proxies: {"host": "localhost", "port": 8000} """ + self.capture = None + self.thread_cancelled = False + self.callback = callback + capture_options = 'rtsp_transport;' self.ip = ip self.username = username @@ -22,7 +37,7 @@ def __init__(self, ip, username, password, port=554, profile="main", use_udp=Tru self.port = port self.proxy = kwargs.get("proxies") self.url = "rtsp://" + self.username + ":" + self.password + "@" + \ - self.ip + ":" + str(self.port) + "//h264Preview_01_" + profile + self.ip + ":" + str(self.port) + "//h264Preview_01_" + profile if use_udp: capture_options = capture_options + 'udp' else: @@ -30,18 +45,67 @@ def __init__(self, ip, username, password, port=554, profile="main", use_udp=Tru os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options - def preview(self): - """ Blocking function. Opens OpenCV window to display stream. """ - win_name = self.ip - cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) - ret, frame = cap.read() + # opens the stream capture, but does not retrieve any frames yet. + self._open_video_capture() + + def _open_video_capture(self): + # To CAP_FFMPEG or not To ? + self.capture = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) + + def _stream_blocking(self): + while True: + try: + if self.capture.isOpened(): + ret, frame = self.capture.read() + if ret: + yield frame + else: + print("stream closed") + self.capture.release() + return + except Exception as e: + print(e) + self.capture.release() + return + + @threaded + def _stream_non_blocking(self): + while not self.thread_cancelled: + try: + if self.capture.isOpened(): + ret, frame = self.capture.read() + if ret: + self.callback(frame) + else: + print("stream is closed") + self.stop_stream() + except ThreadError as e: + print(e) + self.stop_stream() - while ret: - cv2.imshow(win_name, frame) + def stop_stream(self): + self.capture.release() + self.thread_cancelled = True - ret, frame = cap.read() - if (cv2.waitKey(1) & 0xFF == ord('q')): - break + def open_stream(self): + """ + 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. + """ - cap.release() - cv2.destroyAllWindows() + # Reset the capture object + if self.capture is None or not self.capture.isOpened(): + self._open_video_capture() + + print("opening stream") + + if self.callback is None: + return self._stream_blocking() + else: + # reset the thread status if the object was not re-created + if not self.thread_cancelled: + self.thread_cancelled = False + return self._stream_non_blocking() diff --git a/api/__init__.py b/api/__init__.py index 916ada9..491da40 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,4 +1,4 @@ from .APIHandler import APIHandler -__version__ = "0.0.4" +__version__ = "0.0.5" VERSION = __version__ diff --git a/api/recording.py b/api/recording.py index 259e56a..8827249 100644 --- a/api/recording.py +++ b/api/recording.py @@ -9,6 +9,7 @@ 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. @@ -53,34 +54,35 @@ def set_recording_encoding(self, 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}} - }}] + {"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, profile: str = "main", proxies=None) -> None: + 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) - rtsp_client.preview() + 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: """ diff --git a/examples/streaming_video.py b/examples/streaming_video.py new file mode 100644 index 0000000..27463f8 --- /dev/null +++ b/examples/streaming_video.py @@ -0,0 +1,82 @@ +import cv2 + +from Camera import Camera + + +def non_blocking(): + print("calling non-blocking") + + def inner_callback(img): + cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600)) + print("got the image non-blocking") + key = cv2.waitKey(1) + if key == ord('q'): + cv2.destroyAllWindows() + exit(1) + + c = Camera("192.168.1.112", "admin", "jUa2kUzi") + # t in this case is a thread + t = c.open_video_stream(callback=inner_callback) + + print(t.isAlive()) + while True: + if not t.isAlive(): + print("continuing") + break + # stop the stream + # client.stop_stream() + + +def blocking(): + c = Camera("192.168.1.112", "admin", "jUa2kUzi") + # stream in this case is a generator returning an image (in mat format) + stream = c.open_video_stream() + + # using next() + # while True: + # img = next(stream) + # cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600)) + # print("got the image blocking") + # key = cv2.waitKey(1) + # if key == ord('q'): + # cv2.destroyAllWindows() + # exit(1) + + # or using a for loop + for img in stream: + cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600)) + print("got the image blocking") + key = cv2.waitKey(1) + if key == ord('q'): + cv2.destroyAllWindows() + exit(1) + + +# Resizes a image and maintains aspect ratio +def maintain_aspect_ratio_resize(image, width=None, height=None, inter=cv2.INTER_AREA): + # Grab the image size and initialize dimensions + dim = None + (h, w) = image.shape[:2] + + # Return original image if no need to resize + if width is None and height is None: + return image + + # We are resizing height if width is none + if width is None: + # Calculate the ratio of the height and construct the dimensions + r = height / float(h) + dim = (int(w * r), height) + # We are resizing width if height is none + else: + # Calculate the ratio of the 0idth and construct the dimensions + r = width / float(w) + dim = (width, int(h * r)) + + # Return the resized image + return cv2.resize(image, dim, interpolation=inter) + + +# Call the methods. Either Blocking (using generator) or Non-Blocking using threads +# non_blocking() +blocking() diff --git a/util.py b/util.py new file mode 100644 index 0000000..c824002 --- /dev/null +++ b/util.py @@ -0,0 +1,11 @@ +from threading import Thread + + +def threaded(fn): + def wrapper(*args, **kwargs): + thread = Thread(target=fn, args=args, kwargs=kwargs) + thread.daemon = True + thread.start() + return thread + + return wrapper \ No newline at end of file From 0c00397f78ff8b46c18508742b793350b37ce0b0 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Mon, 7 Dec 2020 03:34:05 -0600 Subject: [PATCH 044/103] Change isAlive() to is_alive() --- examples/streaming_video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/streaming_video.py b/examples/streaming_video.py index 27463f8..90dc2a9 100644 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -18,9 +18,9 @@ def inner_callback(img): # t in this case is a thread t = c.open_video_stream(callback=inner_callback) - print(t.isAlive()) + print(t.is_alive()) while True: - if not t.isAlive(): + if not t.is_alive(): print("continuing") break # stop the stream From 984747e1db3e8f8be4d1ea55b8faa2f96eea1b39 Mon Sep 17 00:00:00 2001 From: Alano Date: Mon, 7 Dec 2020 12:09:12 +0200 Subject: [PATCH 045/103] Updated to v0.0.5 Updated readme. Updated twine package script. --- README.md | 15 +++++++++++++-- make-and-publish-package.sh | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cd90018..d6f2bd1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,9 @@ -## ReolinkCameraAPI +## Reolink Python Api Client + +A Reolink Camera client written in Python. + +Other Supported Languages: + - Go: [reolink-go-api](https://github.com/ReolinkCameraAPI/reolink-go-api) ### Join us on Discord @@ -21,11 +26,13 @@ You can get the Restful API calls by looking through the HTTP Requests made the Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. +See the `examples` directory. + ### Using the library as a Python Module Install the package via Pip - pip install reolink-api==0.0.1 + pip install reolink-api==0.0.5 ### Styling and Standards @@ -33,6 +40,10 @@ This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-00 ### API Requests Implementation Plan: +Stream: +- [X] Blocking RTSP stream +- [X] Non-Blocking RTSP stream + GET: - [X] Login - [X] Logout diff --git a/make-and-publish-package.sh b/make-and-publish-package.sh index 43cca45..bf414e6 100755 --- a/make-and-publish-package.sh +++ b/make-and-publish-package.sh @@ -1,3 +1,3 @@ -rm -fr dist -python setup.py sdist +rm -rf dist +python setup.py sdist bdist_wheel twine upload dist/* \ No newline at end of file From d1b0bc2b0067eb2d90c3d83d1e8ff63d3dbb609c Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Mon, 7 Dec 2020 14:33:35 -0600 Subject: [PATCH 046/103] Add Login and Logout json responses --- examples/response/Login.json | 7 +++++++ examples/response/Logout.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 examples/response/Login.json create mode 100644 examples/response/Logout.json diff --git a/examples/response/Login.json b/examples/response/Login.json new file mode 100644 index 0000000..5ce4120 --- /dev/null +++ b/examples/response/Login.json @@ -0,0 +1,7 @@ +[ + { + "cmd": "Login", + "code": 0, + "value": { "Token": { "leaseTime": 3600, "name": "xxxxxxxxxxxx" } } + } +] diff --git a/examples/response/Logout.json b/examples/response/Logout.json new file mode 100644 index 0000000..a0588b2 --- /dev/null +++ b/examples/response/Logout.json @@ -0,0 +1,7 @@ +[ + { + "cmd": "Logout", + "code": 0, + "value": { "rspCode": 200 } + } +] From 20cf272044b0c98b752e8eb4a1aa732a5759b9b0 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Wed, 9 Dec 2020 15:50:14 -0600 Subject: [PATCH 047/103] Resolve issues #32, #33, #28 --- examples/response/GetGeneralSystem.json | 100 +++++++++++++++++++++ examples/response/Reboot.json | 9 ++ examples/response/SetAdvImageSettings.json | 10 +++ examples/response/SetImageSettings.json | 10 +++ 4 files changed, 129 insertions(+) create mode 100644 examples/response/GetGeneralSystem.json create mode 100644 examples/response/Reboot.json create mode 100644 examples/response/SetAdvImageSettings.json create mode 100644 examples/response/SetImageSettings.json diff --git a/examples/response/GetGeneralSystem.json b/examples/response/GetGeneralSystem.json new file mode 100644 index 0000000..814671e --- /dev/null +++ b/examples/response/GetGeneralSystem.json @@ -0,0 +1,100 @@ +[ + { + "cmd": "GetTime", + "code": 0, + "initial": { + "Dst": { + "enable": 0, + "endHour": 2, + "endMin": 0, + "endMon": 10, + "endSec": 0, + "endWeek": 5, + "endWeekday": 0, + "offset": 1, + "startHour": 2, + "startMin": 0, + "startMon": 3, + "startSec": 0, + "startWeek": 2, + "startWeekday": 0 + }, + "Time": { + "day": 1, + "hour": 0, + "hourFmt": 0, + "min": 0, + "mon": 0, + "sec": 0, + "timeFmt": "DD/MM/YYYY", + "timeZone": 28800, + "year": 0 + } + }, + "range": { + "Dst": { + "enable": "boolean", + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "endMon": { "max": 12, "min": 1 }, + "endSec": { "max": 59, "min": 0 }, + "endWeek": { "max": 5, "min": 1 }, + "endWeekday": { "max": 6, "min": 0 }, + "offset": { "max": 2, "min": 1 }, + "startHour": { "max": 23, "min": 0 }, + "startMin": { "max": 59, "min": 0 }, + "startMon": { "max": 12, "min": 1 }, + "startSec": { "max": 59, "min": 0 }, + "startWeek": { "max": 5, "min": 1 }, + "startWeekday": { "max": 6, "min": 0 } + }, + "Time": { + "day": { "max": 31, "min": 1 }, + "hour": { "max": 23, "min": 0 }, + "hourFmt": { "max": 1, "min": 0 }, + "min": { "max": 59, "min": 0 }, + "mon": { "max": 12, "min": 1 }, + "sec": { "max": 59, "min": 0 }, + "timeFmt": ["MM/DD/YYYY", "YYYY/MM/DD", "DD/MM/YYYY"], + "timeZone": { "max": 43200, "min": -46800 }, + "year": { "max": 2100, "min": 1900 } + } + }, + "value": { + "Dst": { + "enable": 1, + "endHour": 2, + "endMin": 0, + "endMon": 11, + "endSec": 0, + "endWeek": 1, + "endWeekday": 0, + "offset": 1, + "startHour": 2, + "startMin": 0, + "startMon": 3, + "startSec": 0, + "startWeek": 1, + "startWeekday": 0 + }, + "Time": { + "day": 9, + "hour": 15, + "hourFmt": 0, + "min": 33, + "mon": 12, + "sec": 58, + "timeFmt": "MM/DD/YYYY", + "timeZone": 21600, + "year": 2020 + } + } + }, + { + "cmd": "GetNorm", + "code": 0, + "initial": { "norm": "NTSC" }, + "range": { "norm": ["PAL", "NTSC"] }, + "value": { "norm": "NTSC" } + } +] diff --git a/examples/response/Reboot.json b/examples/response/Reboot.json new file mode 100644 index 0000000..eb10195 --- /dev/null +++ b/examples/response/Reboot.json @@ -0,0 +1,9 @@ +[ + { + "cmd": "Reboot", + "code": 0, + "value": { + "rspCode": 200 + } + } +] diff --git a/examples/response/SetAdvImageSettings.json b/examples/response/SetAdvImageSettings.json new file mode 100644 index 0000000..780adf3 --- /dev/null +++ b/examples/response/SetAdvImageSettings.json @@ -0,0 +1,10 @@ +[ + { + "cmd" : "SetIsp", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] + \ No newline at end of file diff --git a/examples/response/SetImageSettings.json b/examples/response/SetImageSettings.json new file mode 100644 index 0000000..26ef93d --- /dev/null +++ b/examples/response/SetImageSettings.json @@ -0,0 +1,10 @@ +[ + { + "cmd" : "SetImage", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] + \ No newline at end of file From 3ca26f880ee7b918855df1015d7c36e7dd5c71d6 Mon Sep 17 00:00:00 2001 From: Karl Moos Date: Wed, 9 Dec 2020 16:10:47 -0600 Subject: [PATCH 048/103] Resolve issue #30 --- examples/response/PtzCtrl.json | 9 +++++++++ examples/response/SetPtzPreset.json | 10 ++++++++++ 2 files changed, 19 insertions(+) create mode 100644 examples/response/PtzCtrl.json create mode 100644 examples/response/SetPtzPreset.json diff --git a/examples/response/PtzCtrl.json b/examples/response/PtzCtrl.json new file mode 100644 index 0000000..fa8e1fb --- /dev/null +++ b/examples/response/PtzCtrl.json @@ -0,0 +1,9 @@ +[ + { + "cmd" : "PtzCtrl", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] \ No newline at end of file diff --git a/examples/response/SetPtzPreset.json b/examples/response/SetPtzPreset.json new file mode 100644 index 0000000..84121bc --- /dev/null +++ b/examples/response/SetPtzPreset.json @@ -0,0 +1,10 @@ +[ + { + "cmd" : "SetPtzPreset", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] + \ No newline at end of file From fd9a735ea0e7a6adcf6fb55ee03297179ffcf2c2 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Fri, 11 Dec 2020 18:21:21 +0200 Subject: [PATCH 049/103] Update README.md --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d6f2bd1..a6c742c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,11 @@ -## Reolink Python Api Client +

Reolink Python Api Client

+ +

+ GitHub + GitHub tag (latest SemVer) +

+ +--- A Reolink Camera client written in Python. @@ -34,6 +41,10 @@ Install the package via Pip pip install reolink-api==0.0.5 +## Contributors + +--- + ### Styling and Standards This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) From 1792d47d4cf8eec142306f9b65bceb1b63e0eb9f Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Fri, 11 Dec 2020 18:37:23 +0200 Subject: [PATCH 050/103] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a6c742c..37f295f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

GitHub GitHub tag (latest SemVer) + PyPI

--- From 91c92b0fcb721f94f9472a432f3e8d3e81774135 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 12:41:46 -0600 Subject: [PATCH 051/103] Apply intial changes to make repo perform better as a Python module --- Pipfile | 16 -- Pipfile.lock | 169 ------------------ README.md | 113 +----------- api/__init__.py | 4 - {api => reolink_api}/APIHandler.py | 30 ++-- Camera.py => reolink_api/Camera.py | 4 +- .../ConfigHandler.py | 1 - RtspClient.py => reolink_api/RtspClient.py | 7 +- reolink_api/__init__.py | 4 + {api => reolink_api}/alarm.py | 0 {api => reolink_api}/device.py | 9 +- {api => reolink_api}/display.py | 0 reolink_api/download.py | 15 ++ {api => reolink_api}/image.py | 42 ++--- reolink_api/motion.py | 41 +++++ {api => reolink_api}/network.py | 6 +- {api => reolink_api}/ptz.py | 0 {api => reolink_api}/recording.py | 2 +- resthandle.py => reolink_api/resthandle.py | 4 +- {api => reolink_api}/system.py | 0 {api => reolink_api}/user.py | 0 util.py => reolink_api/util.py | 0 {api => reolink_api}/zoom.py | 0 requirements.txt | 4 - setup.py | 88 ++++----- test.py | 5 - 26 files changed, 158 insertions(+), 406 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock delete mode 100644 api/__init__.py rename {api => reolink_api}/APIHandler.py (92%) rename Camera.py => reolink_api/Camera.py (84%) rename ConfigHandler.py => reolink_api/ConfigHandler.py (99%) rename RtspClient.py => reolink_api/RtspClient.py (94%) create mode 100644 reolink_api/__init__.py rename {api => reolink_api}/alarm.py (100%) rename {api => reolink_api}/device.py (83%) rename {api => reolink_api}/display.py (100%) create mode 100644 reolink_api/download.py rename {api => reolink_api}/image.py (67%) create mode 100644 reolink_api/motion.py rename {api => reolink_api}/network.py (94%) rename {api => reolink_api}/ptz.py (100%) rename {api => reolink_api}/recording.py (99%) rename resthandle.py => reolink_api/resthandle.py (94%) rename {api => reolink_api}/system.py (100%) rename {api => reolink_api}/user.py (100%) rename util.py => reolink_api/util.py (100%) rename {api => reolink_api}/zoom.py (100%) delete mode 100644 requirements.txt delete mode 100644 test.py diff --git a/Pipfile b/Pipfile deleted file mode 100644 index defbd59..0000000 --- a/Pipfile +++ /dev/null @@ -1,16 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -pillow = "*" -pyyaml = "*" -requests = "*" -numpy = "*" -opencv-python = "*" -pysocks = "*" - -[requires] diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 15b8698..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,169 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "idna": { - "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" - ], - "version": "==2.9" - }, - "numpy": { - "hashes": [ - "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6", - "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e", - "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc", - "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc", - "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a", - "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa", - "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3", - "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121", - "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971", - "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26", - "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd", - "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480", - "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec", - "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77", - "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57", - "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07", - "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572", - "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73", - "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca", - "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474", - "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5" - ], - "index": "pypi", - "version": "==1.18.1" - }, - "opencv-python": { - "hashes": [ - "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3", - "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50", - "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb", - "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b", - "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d", - "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a", - "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d", - "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951", - "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c", - "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098", - "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20", - "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f", - "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145", - "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee", - "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b", - "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e", - "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946", - "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db", - "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3", - "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268", - "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179", - "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2", - "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2", - "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9", - "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944", - "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00", - "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417" - ], - "index": "pypi", - "version": "==4.2.0.32" - }, - "pillow": { - "hashes": [ - "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", - "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", - "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", - "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", - "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", - "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", - "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", - "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", - "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", - "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", - "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", - "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", - "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", - "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", - "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", - "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", - "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", - "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", - "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", - "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", - "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", - "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" - ], - "index": "pypi", - "version": "==7.0.0" - }, - "pysocks": { - "hashes": [ - "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", - "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", - "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" - ], - "index": "pypi", - "version": "==1.7.1" - }, - "pyyaml": { - "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" - ], - "index": "pypi", - "version": "==5.3" - }, - "requests": { - "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" - ], - "index": "pypi", - "version": "==2.23.0" - }, - "urllib3": { - "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" - ], - "version": "==1.25.8" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index 37f295f..e8ef0da 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,15 @@ -

Reolink Python Api Client

- -

- GitHub - GitHub tag (latest SemVer) - PyPI -

- ---- - +# (Forked) Reolink Python Api Client A Reolink Camera client written in Python. -Other Supported Languages: - - Go: [reolink-go-api](https://github.com/ReolinkCameraAPI/reolink-go-api) - -### Join us on Discord - - https://discord.gg/8z3fdAmZJP +_NB! for the original API client of this fork, go [here](https://github.com/ReolinkCameraAPI/reolink-python-api)_ ### Purpose -This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS ) - - -### But Reolink gives an API in their documentation - -Not really. They only deliver a really basic API to retrieve Image data and Video data. - -### How? - -You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network. - -### Get started - -Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. - -See the `examples` directory. - -### Using the library as a Python Module - -Install the package via Pip - - pip install reolink-api==0.0.5 - -## Contributors - ---- - -### Styling and Standards - -This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) - -### API Requests Implementation Plan: +This repository's purpose is to deliver a complete API for the Reolink Camera's, (tested on RLC-522) -Stream: -- [X] Blocking RTSP stream -- [X] Non-Blocking RTSP stream +### Installation -GET: -- [X] Login -- [X] Logout -- [X] Display -> OSD -- [X] Recording -> Encode (Clear and Fluent Stream) -- [X] Recording -> Advance (Scheduling) -- [X] Network -> General -- [X] Network -> Advanced -- [X] Network -> DDNS -- [X] Network -> NTP -- [X] Network -> E-mail -- [X] Network -> FTP -- [X] Network -> Push -- [X] Network -> WIFI -- [X] Alarm -> Motion -- [X] System -> General -- [X] System -> DST -- [X] System -> Information -- [ ] System -> Maintenance -- [X] System -> Performance -- [ ] System -> Reboot -- [X] User -> Online User -- [X] User -> Add User -- [X] User -> Manage User -- [X] Device -> HDD/SD Card -- [ ] Zoom -- [ ] Focus -- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) -- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) -- [X] Image Data -> "Snap" Frame from Video Stream +```bash +python3 -m pip install git+https://github.com/barretobrock/reolink-python-api.git +``` -SET: -- [X] Display -> OSD -- [X] Recording -> Encode (Clear and Fluent Stream) -- [ ] Recording -> Advance (Scheduling) -- [X] Network -> General -- [X] Network -> Advanced -- [ ] Network -> DDNS -- [ ] Network -> NTP -- [ ] Network -> E-mail -- [ ] Network -> FTP -- [ ] Network -> Push -- [X] Network -> WIFI -- [ ] Alarm -> Motion -- [ ] System -> General -- [ ] System -> DST -- [X] System -> Reboot -- [X] User -> Online User -- [X] User -> Add User -- [X] User -> Manage User -- [X] Device -> HDD/SD Card (Format) -- [x] PTZ -- [x] Zoom -- [x] Focus -- [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) -- [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) 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/APIHandler.py b/reolink_api/APIHandler.py similarity index 92% rename from api/APIHandler.py rename to reolink_api/APIHandler.py index a4a6f07..3b4754d 100644 --- a/api/APIHandler.py +++ b/reolink_api/APIHandler.py @@ -1,26 +1,30 @@ -from .recording import RecordingAPIMixin -from .zoom import ZoomAPIMixin +from reolink_api.resthandle import Request +from .alarm import AlarmAPIMixin from .device import DeviceAPIMixin from .display import DisplayAPIMixin +from .download import DownloadAPIMixin +from .image import ImageAPIMixin +from .motion import MotionAPIMixin from .network import NetworkAPIMixin +from .ptz import PtzAPIMixin +from .recording import RecordingAPIMixin from .system import SystemAPIMixin from .user import UserAPIMixin -from .ptz import PtzAPIMixin -from .alarm import AlarmAPIMixin -from .image import ImageAPIMixin -from resthandle import Request +from .zoom import ZoomAPIMixin -class APIHandler(SystemAPIMixin, - NetworkAPIMixin, - UserAPIMixin, +class APIHandler(AlarmAPIMixin, DeviceAPIMixin, DisplayAPIMixin, - RecordingAPIMixin, - ZoomAPIMixin, + DownloadAPIMixin, + ImageAPIMixin, + MotionAPIMixin, + NetworkAPIMixin, PtzAPIMixin, - AlarmAPIMixin, - ImageAPIMixin): + RecordingAPIMixin, + SystemAPIMixin, + UserAPIMixin, + ZoomAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. diff --git a/Camera.py b/reolink_api/Camera.py similarity index 84% rename from Camera.py rename to reolink_api/Camera.py index a60490a..0a166f1 100644 --- a/Camera.py +++ b/reolink_api/Camera.py @@ -1,9 +1,9 @@ -from api import APIHandler +from reolink_api import APIHandler class Camera(APIHandler): - def __init__(self, ip, username="admin", password="", https=False): + def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = False): """ Initialise the Camera object by passing the ip address. The default details {"username":"admin", "password":""} will be used if nothing passed diff --git a/ConfigHandler.py b/reolink_api/ConfigHandler.py similarity index 99% rename from ConfigHandler.py rename to reolink_api/ConfigHandler.py index 67e8d62..37f255e 100644 --- a/ConfigHandler.py +++ b/reolink_api/ConfigHandler.py @@ -1,5 +1,4 @@ import io - import yaml diff --git a/RtspClient.py b/reolink_api/RtspClient.py similarity index 94% rename from RtspClient.py rename to reolink_api/RtspClient.py index 6cf37c1..655f95f 100644 --- a/RtspClient.py +++ b/reolink_api/RtspClient.py @@ -1,9 +1,7 @@ import os from threading import ThreadError - import cv2 - -from util import threaded +from reolink_api.util import threaded class RtspClient: @@ -91,9 +89,6 @@ def open_stream(self): """ 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/reolink_api/__init__.py b/reolink_api/__init__.py new file mode 100644 index 0000000..1eca8c1 --- /dev/null +++ b/reolink_api/__init__.py @@ -0,0 +1,4 @@ +from .APIHandler import APIHandler +from .Camera import Camera + +__version__ = "0.1.0" diff --git a/api/alarm.py b/reolink_api/alarm.py similarity index 100% rename from api/alarm.py rename to reolink_api/alarm.py diff --git a/api/device.py b/reolink_api/device.py similarity index 83% rename from api/device.py rename to reolink_api/device.py index deee890..68f8179 100644 --- a/api/device.py +++ b/reolink_api/device.py @@ -1,5 +1,10 @@ +from typing import List + + class DeviceAPIMixin: """API calls for getting device information.""" + DEFAULT_HDD_ID = [0] + def get_hdd_info(self) -> object: """ Gets all HDD and SD card information from Camera @@ -9,12 +14,14 @@ def get_hdd_info(self) -> object: 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[int] = 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/reolink_api/display.py similarity index 100% rename from api/display.py rename to reolink_api/display.py diff --git a/reolink_api/download.py b/reolink_api/download.py new file mode 100644 index 0000000..45494d9 --- /dev/null +++ b/reolink_api/download.py @@ -0,0 +1,15 @@ +class DownloadAPIMixin: + """API calls for downloading video files.""" + def get_file(self, filename: str) -> object: + """ + Download the selected video file + :return: response json + """ + body = [ + { + "cmd": "Download", + "source": filename, + "output": filename + } + ] + return self._execute_command('Download', body) diff --git a/api/image.py b/reolink_api/image.py similarity index 67% rename from api/image.py rename to reolink_api/image.py index 6cdb823..0fbb952 100644 --- a/api/image.py +++ b/reolink_api/image.py @@ -3,22 +3,22 @@ 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: int = 1, + gain_max: int = 62, + shutter_min: int = 1, + shutter_max: int = 125, + blue_gain: int = 128, + red_gain: int = 128, + white_balance: str = 'Auto', + day_night: str = 'Auto', + back_light: str = 'DynamicRangeControl', + blc: int = 128, + drc: int = 128, + rotation: int = 0, + mirroring: int = 0, + nr3d: int = 1) -> object: """ Sets the advanced camera settings. @@ -66,11 +66,11 @@ def set_adv_image_settings(self, return self._execute_command('SetIsp', body) def set_image_settings(self, - brightness=128, - contrast=62, - hue=1, - saturation=125, - sharpness=128) -> object: + brightness: int = 128, + contrast: int = 62, + hue: int = 1, + saturation: int = 125, + sharpness: int = 128) -> object: """ Sets the camera image settings. diff --git a/reolink_api/motion.py b/reolink_api/motion.py new file mode 100644 index 0000000..1ce8071 --- /dev/null +++ b/reolink_api/motion.py @@ -0,0 +1,41 @@ +from datetime import datetime as dt + + +class MotionAPIMixin: + """API calls for past motion alerts.""" + def get_motion_files(self, start: dt, end: dt = dt.now(), + streamtype: str = 'main') -> object: + """ + 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}] + return self._execute_command('Search', body) diff --git a/api/network.py b/reolink_api/network.py similarity index 94% rename from api/network.py rename to reolink_api/network.py index 39af7b8..54bbe2d 100644 --- a/api/network.py +++ b/reolink_api/network.py @@ -1,7 +1,7 @@ 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: int = 80, https_port: int = 443, media_port: int = 9000, + onvif_port: int = 8000, rtmp_port: int = 1935, rtsp_port: int = 554) -> bool: """ Set network ports If nothing is specified, the default values will be used @@ -25,7 +25,7 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port print("Successfully Set Network Ports") return True - def set_wifi(self, ssid, password) -> object: + def set_wifi(self, ssid: str, password: str) -> object: body = [{"cmd": "SetWifi", "action": 0, "param": { "Wifi": { "ssid": ssid, diff --git a/api/ptz.py b/reolink_api/ptz.py similarity index 100% rename from api/ptz.py rename to reolink_api/ptz.py diff --git a/api/recording.py b/reolink_api/recording.py similarity index 99% rename from api/recording.py rename to reolink_api/recording.py index 8827249..195cce7 100644 --- a/api/recording.py +++ b/reolink_api/recording.py @@ -4,7 +4,7 @@ from urllib import parse from io import BytesIO from PIL import Image -from RtspClient import RtspClient +from reolink_api.RtspClient import RtspClient class RecordingAPIMixin: diff --git a/resthandle.py b/reolink_api/resthandle.py similarity index 94% rename from resthandle.py rename to reolink_api/resthandle.py index d2f98b7..ac3fad9 100644 --- a/resthandle.py +++ b/reolink_api/resthandle.py @@ -1,5 +1,4 @@ import json - import requests @@ -17,7 +16,8 @@ def post(url: str, data, params=None) -> requests.Response or None: """ try: headers = {'content-type': 'application/json'} - r = requests.post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies) + 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: diff --git a/api/system.py b/reolink_api/system.py similarity index 100% rename from api/system.py rename to reolink_api/system.py diff --git a/api/user.py b/reolink_api/user.py similarity index 100% rename from api/user.py rename to reolink_api/user.py diff --git a/util.py b/reolink_api/util.py similarity index 100% rename from util.py rename to reolink_api/util.py diff --git a/api/zoom.py b/reolink_api/zoom.py similarity index 100% rename from api/zoom.py rename to reolink_api/zoom.py diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 30b468d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests -opencv-python -numpy -socks \ No newline at end of file diff --git a/setup.py b/setup.py index 98eba70..da818d1 100644 --- a/setup.py +++ b/setup.py @@ -1,32 +1,9 @@ #!/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() - def read(*parts): with codecs.open(os.path.join(here, *parts), 'r') as fp: @@ -41,32 +18,39 @@ 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' - ] - ) +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() + + +# 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 = [ + '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', +] + + +setup( + name=NAME, + python_requires='>=3.6.0', + version=find_version('reolink_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 +) 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() From 00835e3543e90adf35eec824626a352f007c6df2 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 13:38:20 -0600 Subject: [PATCH 052/103] Add changes from original examples file, apply find_packages to setup, add tests --- .gitignore | 1 + examples/streaming_video.py | 3 +-- setup.py | 5 +++-- tests/test_camera.py | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 tests/test_camera.py 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/examples/streaming_video.py b/examples/streaming_video.py index 90dc2a9..d67b1d0 100644 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -1,6 +1,5 @@ import cv2 - -from Camera import Camera +from reolink_api.Camera import Camera def non_blocking(): diff --git a/setup.py b/setup.py index da818d1..e89fd5b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import os import re import codecs -from setuptools import setup +from setuptools import setup, find_packages def read(*parts): @@ -52,5 +52,6 @@ def find_version(*file_paths): author_email=AUTHOR_EMAIL, url=URL, license=LICENSE, - install_requires=INSTALL_REQUIRES + install_requires=INSTALL_REQUIRES, + packages=find_packages(exclude=['examples', 'tests']) ) diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..2fa5cf2 --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,40 @@ +import os +from configparser import RawConfigParser +import unittest +from reolink_api 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() From 86117de420d766300878e39bfdbb8675d4dc94cb Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 14:56:10 -0600 Subject: [PATCH 053/103] Improvements to motion detection and download methods, add to examples --- examples/download_motions.py | 43 ++++++++++++++++++++++++++++++++++++ reolink_api/APIHandler.py | 19 +++++++++++++++- reolink_api/download.py | 9 +++++--- reolink_api/motion.py | 42 +++++++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 examples/download_motions.py diff --git a/examples/download_motions.py b/examples/download_motions.py new file mode 100644 index 0000000..31f66f8 --- /dev/null +++ b/examples/download_motions.py @@ -0,0 +1,43 @@ +import os +from configparser import RawConfigParser +from datetime import datetime as dt, timedelta +from reolink_api 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/reolink_api/APIHandler.py b/reolink_api/APIHandler.py index 3b4754d..093fd2c 100644 --- a/reolink_api/APIHandler.py +++ b/reolink_api/APIHandler.py @@ -1,3 +1,4 @@ +import requests from reolink_api.resthandle import Request from .alarm import AlarmAPIMixin from .device import DeviceAPIMixin @@ -109,7 +110,23 @@ def _execute_command(self, command, data, multi=False): try: if self.token is None: raise ValueError("Login first") - response = Request.post(self.url, data=data, params=params) + 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}") diff --git a/reolink_api/download.py b/reolink_api/download.py index 45494d9..ebd1603 100644 --- a/reolink_api/download.py +++ b/reolink_api/download.py @@ -1,6 +1,6 @@ class DownloadAPIMixin: """API calls for downloading video files.""" - def get_file(self, filename: str) -> object: + def get_file(self, filename: str, output_path: str) -> bool: """ Download the selected video file :return: response json @@ -9,7 +9,10 @@ def get_file(self, filename: str) -> object: { "cmd": "Download", "source": filename, - "output": filename + "output": filename, + "filepath": output_path } ] - return self._execute_command('Download', body) + resp = self._execute_command('Download', body) + + return resp diff --git a/reolink_api/motion.py b/reolink_api/motion.py index 1ce8071..246a74a 100644 --- a/reolink_api/motion.py +++ b/reolink_api/motion.py @@ -1,10 +1,16 @@ +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, int, 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 = 'main') -> object: + streamtype: str = 'sub') -> PROCESSED_MOTION_LIST_TYPE: """ Get the timestamps and filenames of motion detection events for the time range provided. @@ -38,4 +44,36 @@ def get_motion_files(self, start: dt, end: dt = dt.now(), } } body = [{"cmd": "Search", "action": 1, "param": search_params}] - return self._execute_command('Search', body) + + resp = self._execute_command('Search', body)[0] + files = resp['value']['SearchResult']['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 From 7afa58a5ce0056cc9fa79128b8bae1322aa7b82c Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 14:56:40 -0600 Subject: [PATCH 054/103] Version bump --- reolink_api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolink_api/__init__.py b/reolink_api/__init__.py index 1eca8c1..6d78770 100644 --- a/reolink_api/__init__.py +++ b/reolink_api/__init__.py @@ -1,4 +1,4 @@ from .APIHandler import APIHandler from .Camera import Camera -__version__ = "0.1.0" +__version__ = "0.1.1" From bd6d39f965cf1354cfd5af2267bb251a1c56e640 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Mon, 14 Dec 2020 08:53:39 +0200 Subject: [PATCH 055/103] Update README.md --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 37f295f..b66d58d 100644 --- a/README.md +++ b/README.md @@ -112,3 +112,14 @@ SET: - [x] Focus - [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) - [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) + +### Supported Camera's + +Any Reolink camera that has a web UI should work. The other's requiring special Reolink clients +do not work and is not supported here. + +- RLC-411WS +- RLC-423 +- RLC-420-5MP +- RLC-410-5MP +- RLC-520 From 17561a21da8609871664ee27a7e784b651488444 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Mon, 14 Dec 2020 22:18:26 +0200 Subject: [PATCH 056/103] Update README.md --- README.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b66d58d..d0d3a6c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@

Reolink Python Api Client

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

--- -A Reolink Camera client written in Python. +A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Camera's, +although they have a basic API document - it does not satisfy the need for extensive camera communication. Other Supported Languages: - Go: [reolink-go-api](https://github.com/ReolinkCameraAPI/reolink-go-api) @@ -17,18 +20,7 @@ Other Supported Languages: https://discord.gg/8z3fdAmZJP -### Purpose -This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS ) - - -### But Reolink gives an API in their documentation - -Not really. They only deliver a really basic API to retrieve Image data and Video data. - -### How? - -You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network. ### Get started @@ -50,6 +42,20 @@ Install the package via Pip This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) +### How can I become a contributor? + +#### Step 1 + +Get the Restful API calls by looking through the HTTP Requests made in the camera's web UI. I use Google Chrome developer mode (ctr + shift + i) -> Network. + +#### Step 2 + +Fork the repository and make your changes. + +#### Step 3 + +Make a pull request. + ### API Requests Implementation Plan: Stream: @@ -113,7 +119,7 @@ SET: - [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) - [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) -### Supported Camera's +### Supported Cameras Any Reolink camera that has a web UI should work. The other's requiring special Reolink clients do not work and is not supported here. From 856ede1b390b8832ad4addae4629b8a8b2d10ca2 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Wed, 16 Dec 2020 07:45:17 -0600 Subject: [PATCH 057/103] Resolve issue when querying motion files results in empty results --- reolink_api/motion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reolink_api/motion.py b/reolink_api/motion.py index 246a74a..63a8ecf 100644 --- a/reolink_api/motion.py +++ b/reolink_api/motion.py @@ -46,7 +46,8 @@ def get_motion_files(self, start: dt, end: dt = dt.now(), body = [{"cmd": "Search", "action": 1, "param": search_params}] resp = self._execute_command('Search', body)[0] - files = resp['value']['SearchResult']['File'] + result = resp['value']['SearchResult'] + files = result.get('File', []) if len(files) > 0: # Begin processing files processed_files = self._process_motion_files(files) From 0221215e33e5f2a34b3b90121b66e85bda461dd7 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Fri, 18 Dec 2020 04:07:45 +0200 Subject: [PATCH 058/103] Update README.md --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d0d3a6c..19332b7 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,29 @@ --- -A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Camera's, +A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Cameras, although they have a basic API document - it does not satisfy the need for extensive camera communication. +Check out our documentation for more information on how to use the software at [https://reolink.oleaintueri.com](https://reolink.oleaintueri.com) + + Other Supported Languages: - - Go: [reolink-go-api](https://github.com/ReolinkCameraAPI/reolink-go-api) + - Go: [reolinkapigo](https://github.com/ReolinkCameraAPI/reolinkapigo) ### Join us on Discord https://discord.gg/8z3fdAmZJP + + +### Sponsorship + + +[Oleaintueri](https://oleaintueri.com) is sponsoring the development and maintenance of these projects within their organisation. +--- + ### Get started Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. @@ -34,6 +45,10 @@ Install the package via Pip pip install reolink-api==0.0.5 +Install from GitHub + + pip install git+https://github.com/ReolinkCameraAPI/reolink-python-api.git + ## Contributors --- From 84ed8481e278175d77dc085d41197d143d5e84f9 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 08:48:56 -0600 Subject: [PATCH 059/103] Add back pipfiles, README --- Pipfile | 16 ++++ Pipfile.lock | 169 +++++++++++++++++++++++++++++++++++ README.md | 149 ++++++++++++++++++++++++++++-- reolink_api/APIHandler.py | 133 --------------------------- reolink_api/Camera.py | 24 ----- reolink_api/ConfigHandler.py | 16 ---- reolink_api/RtspClient.py | 106 ---------------------- 7 files changed, 325 insertions(+), 288 deletions(-) create mode 100644 Pipfile create mode 100644 Pipfile.lock delete mode 100644 reolink_api/APIHandler.py delete mode 100644 reolink_api/Camera.py delete mode 100644 reolink_api/ConfigHandler.py delete mode 100644 reolink_api/RtspClient.py diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..defbd59 --- /dev/null +++ b/Pipfile @@ -0,0 +1,16 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +pillow = "*" +pyyaml = "*" +requests = "*" +numpy = "*" +opencv-python = "*" +pysocks = "*" + +[requires] diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..15b8698 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,169 @@ +{ + "_meta": { + "hash": { + "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + ], + "version": "==2019.11.28" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "numpy": { + "hashes": [ + "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6", + "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e", + "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc", + "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc", + "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a", + "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa", + "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3", + "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121", + "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971", + "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26", + "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd", + "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480", + "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec", + "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77", + "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57", + "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07", + "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572", + "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73", + "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca", + "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474", + "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5" + ], + "index": "pypi", + "version": "==1.18.1" + }, + "opencv-python": { + "hashes": [ + "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3", + "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50", + "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb", + "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b", + "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d", + "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a", + "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d", + "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951", + "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c", + "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098", + "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20", + "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f", + "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145", + "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee", + "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b", + "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e", + "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946", + "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db", + "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3", + "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268", + "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179", + "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2", + "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2", + "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9", + "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944", + "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00", + "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417" + ], + "index": "pypi", + "version": "==4.2.0.32" + }, + "pillow": { + "hashes": [ + "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", + "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", + "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", + "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", + "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", + "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", + "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", + "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", + "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", + "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", + "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", + "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", + "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", + "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", + "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", + "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", + "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", + "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", + "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", + "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", + "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", + "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" + ], + "index": "pypi", + "version": "==7.0.0" + }, + "pysocks": { + "hashes": [ + "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", + "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", + "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" + ], + "index": "pypi", + "version": "==1.7.1" + }, + "pyyaml": { + "hashes": [ + "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", + "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", + "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", + "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", + "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", + "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", + "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", + "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", + "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", + "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", + "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + ], + "index": "pypi", + "version": "==5.3" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "index": "pypi", + "version": "==2.23.0" + }, + "urllib3": { + "hashes": [ + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + ], + "version": "==1.25.8" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index e8ef0da..19332b7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,146 @@ -# (Forked) Reolink Python Api Client -A Reolink Camera client written in Python. +

Reolink Python Api Client

-_NB! for the original API client of this fork, go [here](https://github.com/ReolinkCameraAPI/reolink-python-api)_ +

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

-### Purpose +--- -This repository's purpose is to deliver a complete API for the Reolink Camera's, (tested on RLC-522) +A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Cameras, +although they have a basic API document - it does not satisfy the need for extensive camera communication. -### Installation +Check out our documentation for more information on how to use the software at [https://reolink.oleaintueri.com](https://reolink.oleaintueri.com) -```bash -python3 -m pip install git+https://github.com/barretobrock/reolink-python-api.git -``` +Other Supported Languages: + - Go: [reolinkapigo](https://github.com/ReolinkCameraAPI/reolinkapigo) + +### Join us on Discord + + https://discord.gg/8z3fdAmZJP + + +### Sponsorship + + + +[Oleaintueri](https://oleaintueri.com) is sponsoring the development and maintenance of these projects within their organisation. + + +--- + +### Get started + +Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. + +See the `examples` directory. + +### Using the library as a Python Module + +Install the package via Pip + + pip install reolink-api==0.0.5 + +Install from GitHub + + pip install git+https://github.com/ReolinkCameraAPI/reolink-python-api.git + +## Contributors + +--- + +### Styling and Standards + +This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) + +### How can I become a contributor? + +#### Step 1 + +Get the Restful API calls by looking through the HTTP Requests made in the camera's web UI. I use Google Chrome developer mode (ctr + shift + i) -> Network. + +#### Step 2 + +Fork the repository and make your changes. + +#### Step 3 + +Make a pull request. + +### API Requests Implementation Plan: + +Stream: +- [X] Blocking RTSP stream +- [X] Non-Blocking RTSP stream + +GET: +- [X] Login +- [X] Logout +- [X] Display -> OSD +- [X] Recording -> Encode (Clear and Fluent Stream) +- [X] Recording -> Advance (Scheduling) +- [X] Network -> General +- [X] Network -> Advanced +- [X] Network -> DDNS +- [X] Network -> NTP +- [X] Network -> E-mail +- [X] Network -> FTP +- [X] Network -> Push +- [X] Network -> WIFI +- [X] Alarm -> Motion +- [X] System -> General +- [X] System -> DST +- [X] System -> Information +- [ ] System -> Maintenance +- [X] System -> Performance +- [ ] System -> Reboot +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card +- [ ] Zoom +- [ ] Focus +- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) +- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) +- [X] Image Data -> "Snap" Frame from Video Stream + +SET: +- [X] Display -> OSD +- [X] Recording -> Encode (Clear and Fluent Stream) +- [ ] Recording -> Advance (Scheduling) +- [X] Network -> General +- [X] Network -> Advanced +- [ ] Network -> DDNS +- [ ] Network -> NTP +- [ ] Network -> E-mail +- [ ] Network -> FTP +- [ ] Network -> Push +- [X] Network -> WIFI +- [ ] Alarm -> Motion +- [ ] System -> General +- [ ] System -> DST +- [X] System -> Reboot +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card (Format) +- [x] PTZ +- [x] Zoom +- [x] Focus +- [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) +- [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) + +### Supported Cameras + +Any Reolink camera that has a web UI should work. The other's requiring special Reolink clients +do not work and is not supported here. + +- RLC-411WS +- RLC-423 +- RLC-420-5MP +- RLC-410-5MP +- RLC-520 diff --git a/reolink_api/APIHandler.py b/reolink_api/APIHandler.py deleted file mode 100644 index 093fd2c..0000000 --- a/reolink_api/APIHandler.py +++ /dev/null @@ -1,133 +0,0 @@ -import requests -from reolink_api.resthandle import Request -from .alarm import AlarmAPIMixin -from .device import DeviceAPIMixin -from .display import DisplayAPIMixin -from .download import DownloadAPIMixin -from .image import ImageAPIMixin -from .motion import MotionAPIMixin -from .network import NetworkAPIMixin -from .ptz import PtzAPIMixin -from .recording import RecordingAPIMixin -from .system import SystemAPIMixin -from .user import UserAPIMixin -from .zoom import ZoomAPIMixin - - -class APIHandler(AlarmAPIMixin, - DeviceAPIMixin, - DisplayAPIMixin, - DownloadAPIMixin, - ImageAPIMixin, - MotionAPIMixin, - NetworkAPIMixin, - PtzAPIMixin, - RecordingAPIMixin, - SystemAPIMixin, - UserAPIMixin, - ZoomAPIMixin): - """ - The APIHandler class is the backend part of the API, the actual API calls - are implemented in Mixins. - This handles communication directly with the camera. - Current camera's tested: RLC-411WS - - 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): - """ - Initialise the Camera API Handler (maps api calls into python) - :param ip: - :param username: - :param password: - :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 - """ - scheme = 'https' if https else 'http' - self.url = f"{scheme}://{ip}/cgi-bin/api.cgi" - self.ip = ip - self.token = None - self.username = username - self.password = password - Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found - - def login(self) -> bool: - """ - Get login token - Must be called first, before any other operation can be performed - :return: bool - """ - try: - body = [{"cmd": "Login", "action": 0, - "param": {"User": {"userName": self.username, "password": self.password}}}] - param = {"cmd": "Login", "token": "null"} - response = Request.post(self.url, data=body, params=param) - if response is not None: - data = response.json()[0] - code = data["code"] - if int(code) == 0: - self.token = data["value"]["Token"]["name"] - print("Login success") - return True - print(self.token) - return False - else: - print("Failed to login\nStatus Code:", response.status_code) - return False - except Exception as e: - print("Error Login\n", e) - raise - - def logout(self) -> bool: - """ - Logout of the camera - :return: bool - """ - try: - data = [{"cmd": "Logout", "action": 0}] - self._execute_command('Logout', data) - # print(ret) - return True - except Exception as e: - print("Error Logout\n", e) - return False - - def _execute_command(self, command, data, multi=False): - """ - Send a POST request to the IP camera with given data. - :param command: name of the command to send - :param data: object to send to the camera (send as json) - :param multi: whether the given command name should be added to the - url parameters of the request. Defaults to False. (Some multi-step - commands seem to not have a single command name) - :return: response JSON as python object - """ - params = {"token": self.token, 'cmd': command} - if multi: - del params['cmd'] - try: - if self.token is None: - raise ValueError("Login first") - 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/reolink_api/Camera.py b/reolink_api/Camera.py deleted file mode 100644 index 0a166f1..0000000 --- a/reolink_api/Camera.py +++ /dev/null @@ -1,24 +0,0 @@ -from reolink_api import APIHandler - - -class Camera(APIHandler): - - def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = 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/reolink_api/ConfigHandler.py b/reolink_api/ConfigHandler.py deleted file mode 100644 index 37f255e..0000000 --- a/reolink_api/ConfigHandler.py +++ /dev/null @@ -1,16 +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/reolink_api/RtspClient.py b/reolink_api/RtspClient.py deleted file mode 100644 index 655f95f..0000000 --- a/reolink_api/RtspClient.py +++ /dev/null @@ -1,106 +0,0 @@ -import os -from threading import ThreadError -import cv2 -from reolink_api.util import threaded - - -class RtspClient: - """ - 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): - """ - RTSP client is used to retrieve frames from the camera in a stream - - :param ip: Camera IP - :param username: Camera Username - :param password: Camera User Password - :param port: RTSP port - :param profile: "main" or "sub" - :param use_upd: True to use UDP, False to use TCP - :param proxies: {"host": "localhost", "port": 8000} - """ - self.capture = None - self.thread_cancelled = False - self.callback = callback - - capture_options = 'rtsp_transport;' - self.ip = ip - self.username = username - 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' - - os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options - - # opens the stream capture, but does not retrieve any frames yet. - self._open_video_capture() - - def _open_video_capture(self): - # To CAP_FFMPEG or not To ? - self.capture = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) - - def _stream_blocking(self): - while True: - try: - if self.capture.isOpened(): - ret, frame = self.capture.read() - if ret: - yield frame - else: - print("stream closed") - self.capture.release() - return - except Exception as e: - print(e) - self.capture.release() - return - - @threaded - def _stream_non_blocking(self): - while not self.thread_cancelled: - try: - if self.capture.isOpened(): - ret, frame = self.capture.read() - if ret: - self.callback(frame) - else: - print("stream is closed") - self.stop_stream() - except ThreadError as e: - print(e) - self.stop_stream() - - def stop_stream(self): - self.capture.release() - self.thread_cancelled = True - - def open_stream(self): - """ - 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 - """ - - # Reset the capture object - if self.capture is None or not self.capture.isOpened(): - self._open_video_capture() - - print("opening stream") - - if self.callback is None: - return self._stream_blocking() - else: - # reset the thread status if the object was not re-created - if not self.thread_cancelled: - self.thread_cancelled = False - return self._stream_non_blocking() From b10c29c6be80c4e00afd4645a860c6768d8059e3 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 08:50:32 -0600 Subject: [PATCH 060/103] Move description read-in beneath metadata --- setup.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index e89fd5b..5a7c521 100644 --- a/setup.py +++ b/setup.py @@ -18,12 +18,6 @@ def find_version(*file_paths): raise RuntimeError("Unable to find version string.") -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() - - # Package meta-data. NAME = 'reolink_api' DESCRIPTION = 'Reolink Camera API written in Python 3.6' @@ -41,6 +35,12 @@ def find_version(*file_paths): ] +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', From 17bc207e3bf8a4fc269a0c3abc00449adb464864 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 08:51:16 -0600 Subject: [PATCH 061/103] Add description to new example. Swap import on original example --- examples/download_motions.py | 1 + examples/streaming_video.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/download_motions.py b/examples/download_motions.py index 31f66f8..66d92da 100644 --- a/examples/download_motions.py +++ b/examples/download_motions.py @@ -1,3 +1,4 @@ +"""Downloads all motion events from camera from the past hour.""" import os from configparser import RawConfigParser from datetime import datetime as dt, timedelta diff --git a/examples/streaming_video.py b/examples/streaming_video.py index d67b1d0..8f0a01b 100644 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -1,5 +1,5 @@ import cv2 -from reolink_api.Camera import Camera +from reolink_api import Camera def non_blocking(): From 63537f9daf89c25594162bd13da80b98d174aa0c Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 09:05:56 -0600 Subject: [PATCH 062/103] Enforce lowercase standard on all submodule files, improve type hinting throughout, complete first pass for logic issues --- reolink_api/__init__.py | 6 +- reolink_api/alarm.py | 5 +- reolink_api/api_handler.py | 138 ++++++++++++++++++++++++++++++++++ reolink_api/camera.py | 24 ++++++ reolink_api/config_handler.py | 17 +++++ reolink_api/display.py | 35 +++++---- reolink_api/image.py | 34 +++++---- reolink_api/network.py | 25 +++--- reolink_api/ptz.py | 49 ++++++------ reolink_api/recording.py | 91 ++++++++++++---------- reolink_api/resthandle.py | 11 +-- reolink_api/rtsp_client.py | 110 +++++++++++++++++++++++++++ reolink_api/system.py | 15 ++-- reolink_api/user.py | 7 +- reolink_api/util.py | 2 +- reolink_api/zoom.py | 19 +++-- 16 files changed, 456 insertions(+), 132 deletions(-) create mode 100644 reolink_api/api_handler.py create mode 100644 reolink_api/camera.py create mode 100644 reolink_api/config_handler.py create mode 100644 reolink_api/rtsp_client.py diff --git a/reolink_api/__init__.py b/reolink_api/__init__.py index 6d78770..e623a67 100644 --- a/reolink_api/__init__.py +++ b/reolink_api/__init__.py @@ -1,4 +1,4 @@ -from .APIHandler import APIHandler -from .Camera import Camera +from .api_handler import APIHandler +from .camera import Camera -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/reolink_api/alarm.py b/reolink_api/alarm.py index 2f48efb..53bc6ee 100644 --- a/reolink_api/alarm.py +++ b/reolink_api/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/reolink_api/api_handler.py b/reolink_api/api_handler.py new file mode 100644 index 0000000..aa5c61f --- /dev/null +++ b/reolink_api/api_handler.py @@ -0,0 +1,138 @@ +import requests +from typing import Dict, List, Optional, Union +from reolink_api.alarm import AlarmAPIMixin +from reolink_api.device import DeviceAPIMixin +from reolink_api.display import DisplayAPIMixin +from reolink_api.download import DownloadAPIMixin +from reolink_api.image import ImageAPIMixin +from reolink_api.motion import MotionAPIMixin +from reolink_api.network import NetworkAPIMixin +from reolink_api.ptz import PtzAPIMixin +from reolink_api.recording import RecordingAPIMixin +from reolink_api.resthandle import Request +from reolink_api.system import SystemAPIMixin +from reolink_api.user import UserAPIMixin +from reolink_api.zoom import ZoomAPIMixin + + +class APIHandler(AlarmAPIMixin, + DeviceAPIMixin, + DisplayAPIMixin, + DownloadAPIMixin, + ImageAPIMixin, + MotionAPIMixin, + NetworkAPIMixin, + PtzAPIMixin, + RecordingAPIMixin, + SystemAPIMixin, + UserAPIMixin, + ZoomAPIMixin): + """ + The APIHandler class is the backend part of the API, the actual API calls + are implemented in Mixins. + This handles communication directly with the camera. + Current camera's tested: RLC-411WS + + 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: bool = False, **kwargs): + """ + Initialise the Camera API Handler (maps api calls into python) + :param ip: + :param username: + :param password: + :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 + """ + scheme = 'https' if https else 'http' + self.url = f"{scheme}://{ip}/cgi-bin/api.cgi" + self.ip = ip + self.token = None + self.username = username + self.password = password + Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found + + def login(self) -> bool: + """ + Get login token + Must be called first, before any other operation can be performed + :return: bool + """ + try: + body = [{"cmd": "Login", "action": 0, + "param": {"User": {"userName": self.username, "password": self.password}}}] + param = {"cmd": "Login", "token": "null"} + response = Request.post(self.url, data=body, params=param) + if response is not None: + data = response.json()[0] + code = data["code"] + if int(code) == 0: + self.token = data["value"]["Token"]["name"] + print("Login success") + return True + print(self.token) + return False + else: + # 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) + raise + + def logout(self) -> bool: + """ + Logout of the camera + :return: bool + """ + try: + data = [{"cmd": "Logout", "action": 0}] + self._execute_command('Logout', data) + # print(ret) + return True + except Exception as e: + print("Error Logout\n", e) + return 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 + :param data: object to send to the camera (send as json) + :param multi: whether the given command name should be added to the + url parameters of the request. Defaults to False. (Some multi-step + commands seem to not have a single command name) + :return: response JSON as python object + """ + params = {"token": self.token, 'cmd': command} + if multi: + del params['cmd'] + try: + if self.token is None: + raise ValueError("Login first") + 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/reolink_api/camera.py b/reolink_api/camera.py new file mode 100644 index 0000000..47db97e --- /dev/null +++ b/reolink_api/camera.py @@ -0,0 +1,24 @@ +from .api_handler import APIHandler + + +class Camera(APIHandler): + + def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = 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/reolink_api/config_handler.py b/reolink_api/config_handler.py new file mode 100644 index 0000000..a1c08ec --- /dev/null +++ b/reolink_api/config_handler.py @@ -0,0 +1,17 @@ +import io +import yaml +from typing import Optional, Dict + + +class ConfigHandler: + camera_settings = {} + + @staticmethod + def load() -> Optional[Dict]: + 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/reolink_api/display.py b/reolink_api/display.py index bf2b4ae..68fbb38 100644 --- a/reolink_api/display.py +++ b/reolink_api/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 @@ def get_osd(self) -> object: 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 @@ def get_mask(self) -> object: 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: int = 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 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo :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/reolink_api/image.py b/reolink_api/image.py index 0fbb952..8e30568 100644 --- a/reolink_api/image.py +++ b/reolink_api/image.py @@ -1,3 +1,5 @@ +from typing import Dict + class ImageAPIMixin: """API calls for image settings.""" @@ -5,20 +7,20 @@ class ImageAPIMixin: def set_adv_image_settings(self, anti_flicker: str = 'Outdoor', exposure: str = 'Auto', - gain_min: int = 1, - gain_max: int = 62, - shutter_min: int = 1, - shutter_max: int = 125, - blue_gain: int = 128, - red_gain: int = 128, + 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: int = 128, - drc: int = 128, - rotation: int = 0, - mirroring: int = 0, - nr3d: int = 1) -> object: + 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 @@ def set_adv_image_settings(self, return self._execute_command('SetIsp', body) def set_image_settings(self, - brightness: int = 128, - contrast: int = 62, - hue: int = 1, - saturation: int = 125, - sharpness: int = 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/reolink_api/network.py b/reolink_api/network.py index 54bbe2d..8ec0cc3 100644 --- a/reolink_api/network.py +++ b/reolink_api/network.py @@ -1,3 +1,6 @@ +from typing import Dict + + class NetworkAPIMixin: """API calls for network settings.""" def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: int = 9000, @@ -25,7 +28,7 @@ def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: i print("Successfully Set Network Ports") return True - def set_wifi(self, ssid: str, password: str) -> object: + def set_wifi(self, ssid: str, password: str) -> Dict: body = [{"cmd": "SetWifi", "action": 0, "param": { "Wifi": { "ssid": ssid, @@ -33,7 +36,7 @@ def set_wifi(self, ssid: str, password: str) -> object: }}}] 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 @@ def get_net_ports(self) -> object: {"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 @@ def get_network_general(self) -> object: 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 @@ def get_network_ddns(self) -> object: 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 @@ def get_network_ntp(self) -> object: 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 @@ def get_network_email(self) -> object: 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 @@ def get_network_ftp(self) -> object: 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 @@ def get_network_push(self) -> object: 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/reolink_api/ptz.py b/reolink_api/ptz.py index 463624e..80841a0 100644 --- a/reolink_api/ptz.py +++ b/reolink_api/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 @@ def move_right(self, speed=25): """ 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 @@ def move_right_up(self, speed=25): """ 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 @@ def move_right_down(self, speed=25): """ 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 @@ def move_left(self, speed=25): """ 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 @@ def move_left_up(self, speed=25): """ 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 @@ def move_left_down(self, speed=25): """ 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 @@ def move_up(self, speed=25): """ 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 @@ def move_down(self, speed=25): """ 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/reolink_api/recording.py b/reolink_api/recording.py index 195cce7..2170e42 100644 --- a/reolink_api/recording.py +++ b/reolink_api/recording.py @@ -3,14 +3,15 @@ import string from urllib import parse from io import BytesIO -from PIL import Image -from reolink_api.RtspClient import RtspClient +from typing import Dict, Any, Optional +from PIL.Image import Image, open as open_image +from reolink_api.rtsp_client import RtspClient class RecordingAPIMixin: """API calls for recording/streaming image or video.""" - def get_recording_encoding(self) -> object: + 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. @@ -19,7 +20,7 @@ def get_recording_encoding(self) -> object: body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetEnc', body) - def get_recording_advanced(self) -> object: + def get_recording_advanced(self) -> Dict: """ Get recording advanced setup data See examples/response/GetRec.json for example response data. @@ -29,15 +30,15 @@ def get_recording_advanced(self) -> object: 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: + 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 @@ -51,59 +52,67 @@ def set_recording_encoding(self, :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}} - }}] + 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): + 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 profile: profile is "main" or "sub" + :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: int = 3, proxies=None) -> Image or None: + 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 = {} - 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 + 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 Image.open(BytesIO(response.content)) - print("Could not retrieve data from camera successfully. Status:", response.stats_code) + return open_image(BytesIO(response.content)) + print("Could not retrieve data from camera successfully. Status:", response.status_code) return None except Exception as e: diff --git a/reolink_api/resthandle.py b/reolink_api/resthandle.py index ac3fad9..aa4704d 100644 --- a/reolink_api/resthandle.py +++ b/reolink_api/resthandle.py @@ -1,12 +1,14 @@ 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: @@ -18,10 +20,6 @@ def post(url: str, data, params=None) -> requests.Response or None: 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) if r.status_code == 200: return r else: @@ -31,7 +29,7 @@ def post(url: str, data, params=None) -> requests.Response or None: 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 +39,6 @@ def get(url, params, timeout=1) -> json or None: """ 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/reolink_api/rtsp_client.py b/reolink_api/rtsp_client.py new file mode 100644 index 0000000..75e16bb --- /dev/null +++ b/reolink_api/rtsp_client.py @@ -0,0 +1,110 @@ +import os +from threading import ThreadError +from typing import Any +import cv2 +from reolink_api.util import threaded + + +class RtspClient: + """ + 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: 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 + + :param ip: Camera IP + :param username: Camera Username + :param password: Camera User Password + :param port: RTSP port + :param profile: "main" or "sub" + :param use_upd: True to use UDP, False to use TCP + :param proxies: {"host": "localhost", "port": 8000} + """ + self.capture = None + self.thread_cancelled = False + self.callback = callback + + capture_options = 'rtsp_transport;' + self.ip = ip + self.username = username + self.password = password + self.port = port + self.proxy = kwargs.get("proxies") + self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}' + if use_udp: + capture_options = capture_options + 'udp' + else: + capture_options = capture_options + 'tcp' + + os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options + + # opens the stream capture, but does not retrieve any frames yet. + self._open_video_capture() + + def _open_video_capture(self): + # To CAP_FFMPEG or not To ? + self.capture = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) + + def _stream_blocking(self): + while True: + try: + if self.capture.isOpened(): + ret, frame = self.capture.read() + if ret: + yield frame + else: + print("stream closed") + self.capture.release() + return + except Exception as e: + print(e) + self.capture.release() + return + + @threaded + def _stream_non_blocking(self): + while not self.thread_cancelled: + try: + if self.capture.isOpened(): + ret, frame = self.capture.read() + if ret: + self.callback(frame) + else: + print("stream is closed") + self.stop_stream() + except ThreadError as e: + print(e) + self.stop_stream() + + def stop_stream(self): + self.capture.release() + self.thread_cancelled = True + + def open_stream(self): + """ + 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 + if self.capture is None or not self.capture.isOpened(): + self._open_video_capture() + + print("opening stream") + + if self.callback is None: + return self._stream_blocking() + else: + # reset the thread status if the object was not re-created + if not self.thread_cancelled: + self.thread_cancelled = False + return self._stream_non_blocking() diff --git a/reolink_api/system.py b/reolink_api/system.py index 0eadc6a..dcb590a 100644 --- a/reolink_api/system.py +++ b/reolink_api/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 @@ def get_performance(self) -> object: 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 @@ def get_information(self) -> object: 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 @@ def reboot_camera(self) -> object: 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/reolink_api/user.py b/reolink_api/user.py index 9d430f6..68a3915 100644 --- a/reolink_api/user.py +++ b/reolink_api/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 @@ def get_online_user(self) -> object: 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. diff --git a/reolink_api/util.py b/reolink_api/util.py index c824002..83cf0ba 100644 --- a/reolink_api/util.py +++ b/reolink_api/util.py @@ -8,4 +8,4 @@ def wrapper(*args, **kwargs): thread.start() return thread - return wrapper \ No newline at end of file + return wrapper diff --git a/reolink_api/zoom.py b/reolink_api/zoom.py index 2bf0021..0f5778d 100644 --- a/reolink_api/zoom.py +++ b/reolink_api/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 From 7283bd3cab95c473521d62b63e0bb0a07c8eebc3 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 09:12:15 -0600 Subject: [PATCH 063/103] Reduce redundant code, remove unused arguments --- reolink_api/rtsp_client.py | 8 +------- reolink_api/user.py | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/reolink_api/rtsp_client.py b/reolink_api/rtsp_client.py index 75e16bb..6213ad4 100644 --- a/reolink_api/rtsp_client.py +++ b/reolink_api/rtsp_client.py @@ -37,10 +37,7 @@ def __init__(self, ip: str, username: str, password: str, port: float = 554, pro self.port = port self.proxy = kwargs.get("proxies") self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}' - if use_udp: - capture_options = capture_options + 'udp' - else: - capture_options = capture_options + 'tcp' + capture_options += 'udp' if use_udp else 'tcp' os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options @@ -90,9 +87,6 @@ def open_stream(self): """ 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/reolink_api/user.py b/reolink_api/user.py index 68a3915..c382c2d 100644 --- a/reolink_api/user.py +++ b/reolink_api/user.py @@ -48,7 +48,7 @@ def modify_user(self, username: str, password: str) -> bool: 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: @@ -61,5 +61,5 @@ def delete_user(self, username: str) -> bool: 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 From 0a4898411b612747a2e4b8a34480f3fb2e630ede Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 15:58:01 -0600 Subject: [PATCH 064/103] Complete second logic pass, remove underscore from package name --- {reolink_api => reolinkapi}/__init__.py | 0 {reolink_api => reolinkapi}/alarm.py | 0 {reolink_api => reolinkapi}/api_handler.py | 26 +++++++++---------- {reolink_api => reolinkapi}/camera.py | 0 {reolink_api => reolinkapi}/config_handler.py | 0 {reolink_api => reolinkapi}/device.py | 6 ++--- {reolink_api => reolinkapi}/display.py | 2 +- {reolink_api => reolinkapi}/download.py | 0 {reolink_api => reolinkapi}/image.py | 0 {reolink_api => reolinkapi}/motion.py | 2 +- {reolink_api => reolinkapi}/network.py | 4 +-- {reolink_api => reolinkapi}/ptz.py | 0 {reolink_api => reolinkapi}/recording.py | 2 +- {reolink_api => reolinkapi}/resthandle.py | 1 - {reolink_api => reolinkapi}/rtsp_client.py | 2 +- {reolink_api => reolinkapi}/system.py | 0 {reolink_api => reolinkapi}/user.py | 0 {reolink_api => reolinkapi}/util.py | 0 {reolink_api => reolinkapi}/zoom.py | 0 19 files changed, 22 insertions(+), 23 deletions(-) rename {reolink_api => reolinkapi}/__init__.py (100%) rename {reolink_api => reolinkapi}/alarm.py (100%) rename {reolink_api => reolinkapi}/api_handler.py (89%) rename {reolink_api => reolinkapi}/camera.py (100%) rename {reolink_api => reolinkapi}/config_handler.py (100%) rename {reolink_api => reolinkapi}/device.py (88%) rename {reolink_api => reolinkapi}/display.py (96%) rename {reolink_api => reolinkapi}/download.py (100%) rename {reolink_api => reolinkapi}/image.py (100%) rename {reolink_api => reolinkapi}/motion.py (97%) rename {reolink_api => reolinkapi}/network.py (95%) rename {reolink_api => reolinkapi}/ptz.py (100%) rename {reolink_api => reolinkapi}/recording.py (99%) rename {reolink_api => reolinkapi}/resthandle.py (99%) rename {reolink_api => reolinkapi}/rtsp_client.py (98%) rename {reolink_api => reolinkapi}/system.py (100%) rename {reolink_api => reolinkapi}/user.py (100%) rename {reolink_api => reolinkapi}/util.py (100%) rename {reolink_api => reolinkapi}/zoom.py (100%) diff --git a/reolink_api/__init__.py b/reolinkapi/__init__.py similarity index 100% rename from reolink_api/__init__.py rename to reolinkapi/__init__.py diff --git a/reolink_api/alarm.py b/reolinkapi/alarm.py similarity index 100% rename from reolink_api/alarm.py rename to reolinkapi/alarm.py diff --git a/reolink_api/api_handler.py b/reolinkapi/api_handler.py similarity index 89% rename from reolink_api/api_handler.py rename to reolinkapi/api_handler.py index aa5c61f..3ceae88 100644 --- a/reolink_api/api_handler.py +++ b/reolinkapi/api_handler.py @@ -1,18 +1,18 @@ import requests from typing import Dict, List, Optional, Union -from reolink_api.alarm import AlarmAPIMixin -from reolink_api.device import DeviceAPIMixin -from reolink_api.display import DisplayAPIMixin -from reolink_api.download import DownloadAPIMixin -from reolink_api.image import ImageAPIMixin -from reolink_api.motion import MotionAPIMixin -from reolink_api.network import NetworkAPIMixin -from reolink_api.ptz import PtzAPIMixin -from reolink_api.recording import RecordingAPIMixin -from reolink_api.resthandle import Request -from reolink_api.system import SystemAPIMixin -from reolink_api.user import UserAPIMixin -from reolink_api.zoom import ZoomAPIMixin +from reolinkapi.alarm import AlarmAPIMixin +from reolinkapi.device import DeviceAPIMixin +from reolinkapi.display import DisplayAPIMixin +from reolinkapi.download import DownloadAPIMixin +from reolinkapi.image import ImageAPIMixin +from reolinkapi.motion import MotionAPIMixin +from reolinkapi.network import NetworkAPIMixin +from reolinkapi.ptz import PtzAPIMixin +from reolinkapi.recording import RecordingAPIMixin +from reolinkapi.resthandle import Request +from reolinkapi.system import SystemAPIMixin +from reolinkapi.user import UserAPIMixin +from reolinkapi.zoom import ZoomAPIMixin class APIHandler(AlarmAPIMixin, diff --git a/reolink_api/camera.py b/reolinkapi/camera.py similarity index 100% rename from reolink_api/camera.py rename to reolinkapi/camera.py diff --git a/reolink_api/config_handler.py b/reolinkapi/config_handler.py similarity index 100% rename from reolink_api/config_handler.py rename to reolinkapi/config_handler.py diff --git a/reolink_api/device.py b/reolinkapi/device.py similarity index 88% rename from reolink_api/device.py rename to reolinkapi/device.py index 68f8179..684be45 100644 --- a/reolink_api/device.py +++ b/reolinkapi/device.py @@ -1,11 +1,11 @@ -from typing import List +from typing import List, Dict class DeviceAPIMixin: """API calls for getting device information.""" DEFAULT_HDD_ID = [0] - def get_hdd_info(self) -> object: + def get_hdd_info(self) -> Dict: """ Gets all HDD and SD card information from Camera See examples/response/GetHddInfo.json for example response data. @@ -14,7 +14,7 @@ def get_hdd_info(self) -> object: body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] return self._execute_command('GetHddInfo', body) - def format_hdd(self, hdd_id: List[int] = None) -> 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) diff --git a/reolink_api/display.py b/reolinkapi/display.py similarity index 96% rename from reolink_api/display.py rename to reolinkapi/display.py index 68fbb38..5c4c48c 100644 --- a/reolink_api/display.py +++ b/reolinkapi/display.py @@ -22,7 +22,7 @@ def get_mask(self) -> Dict: 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, + 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: """ diff --git a/reolink_api/download.py b/reolinkapi/download.py similarity index 100% rename from reolink_api/download.py rename to reolinkapi/download.py diff --git a/reolink_api/image.py b/reolinkapi/image.py similarity index 100% rename from reolink_api/image.py rename to reolinkapi/image.py diff --git a/reolink_api/motion.py b/reolinkapi/motion.py similarity index 97% rename from reolink_api/motion.py rename to reolinkapi/motion.py index 63a8ecf..b95746d 100644 --- a/reolink_api/motion.py +++ b/reolinkapi/motion.py @@ -3,7 +3,7 @@ # Type hints for input and output of the motion api response -RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, int, Dict[str, str]]]] +RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, float, Dict[str, str]]]] PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, dt]]] diff --git a/reolink_api/network.py b/reolinkapi/network.py similarity index 95% rename from reolink_api/network.py rename to reolinkapi/network.py index 8ec0cc3..f4fe4a6 100644 --- a/reolink_api/network.py +++ b/reolinkapi/network.py @@ -3,8 +3,8 @@ class NetworkAPIMixin: """API calls for network settings.""" - def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: int = 9000, - onvif_port: int = 8000, rtmp_port: int = 1935, rtsp_port: int = 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 diff --git a/reolink_api/ptz.py b/reolinkapi/ptz.py similarity index 100% rename from reolink_api/ptz.py rename to reolinkapi/ptz.py diff --git a/reolink_api/recording.py b/reolinkapi/recording.py similarity index 99% rename from reolink_api/recording.py rename to reolinkapi/recording.py index 2170e42..41284f2 100644 --- a/reolink_api/recording.py +++ b/reolinkapi/recording.py @@ -5,7 +5,7 @@ from io import BytesIO from typing import Dict, Any, Optional from PIL.Image import Image, open as open_image -from reolink_api.rtsp_client import RtspClient +from reolinkapi.rtsp_client import RtspClient class RecordingAPIMixin: diff --git a/reolink_api/resthandle.py b/reolinkapi/resthandle.py similarity index 99% rename from reolink_api/resthandle.py rename to reolinkapi/resthandle.py index aa4704d..66f172a 100644 --- a/reolink_api/resthandle.py +++ b/reolinkapi/resthandle.py @@ -1,4 +1,3 @@ -import json import requests from typing import List, Dict, Union, Optional diff --git a/reolink_api/rtsp_client.py b/reolinkapi/rtsp_client.py similarity index 98% rename from reolink_api/rtsp_client.py rename to reolinkapi/rtsp_client.py index 6213ad4..0c1db0e 100644 --- a/reolink_api/rtsp_client.py +++ b/reolinkapi/rtsp_client.py @@ -2,7 +2,7 @@ from threading import ThreadError from typing import Any import cv2 -from reolink_api.util import threaded +from reolinkapi.util import threaded class RtspClient: diff --git a/reolink_api/system.py b/reolinkapi/system.py similarity index 100% rename from reolink_api/system.py rename to reolinkapi/system.py diff --git a/reolink_api/user.py b/reolinkapi/user.py similarity index 100% rename from reolink_api/user.py rename to reolinkapi/user.py diff --git a/reolink_api/util.py b/reolinkapi/util.py similarity index 100% rename from reolink_api/util.py rename to reolinkapi/util.py diff --git a/reolink_api/zoom.py b/reolinkapi/zoom.py similarity index 100% rename from reolink_api/zoom.py rename to reolinkapi/zoom.py From 4c4dd7dd697f182f163300e7cc82cfa41e0c5b53 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 15:58:28 -0600 Subject: [PATCH 065/103] Complete second logic pass, remove underscore from package name --- examples/download_motions.py | 2 +- examples/streaming_video.py | 2 +- setup.py | 4 ++-- tests/test_camera.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/download_motions.py b/examples/download_motions.py index 66d92da..59ec181 100644 --- a/examples/download_motions.py +++ b/examples/download_motions.py @@ -2,7 +2,7 @@ import os from configparser import RawConfigParser from datetime import datetime as dt, timedelta -from reolink_api import Camera +from reolinkapi import Camera def read_config(props_path: str) -> dict: diff --git a/examples/streaming_video.py b/examples/streaming_video.py index 8f0a01b..9049ed8 100644 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -1,5 +1,5 @@ import cv2 -from reolink_api import Camera +from reolinkapi import Camera def non_blocking(): diff --git a/setup.py b/setup.py index 5a7c521..500a40f 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def find_version(*file_paths): # Package meta-data. -NAME = 'reolink_api' +NAME = 'reolinkapi' DESCRIPTION = 'Reolink Camera API written in Python 3.6' URL = 'https://github.com/Benehiko/ReolinkCameraAPI' AUTHOR_EMAIL = '' @@ -44,7 +44,7 @@ def find_version(*file_paths): setup( name=NAME, python_requires='>=3.6.0', - version=find_version('reolink_api', '__init__.py'), + version=find_version('reolinkapi', '__init__.py'), description=DESCRIPTION, long_description=long_description, long_description_content_type='text/markdown', diff --git a/tests/test_camera.py b/tests/test_camera.py index 2fa5cf2..67851d0 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,7 +1,7 @@ import os from configparser import RawConfigParser import unittest -from reolink_api import Camera +from reolinkapi import Camera def read_config(props_path: str) -> dict: From 2b3e142fe500a9b71ba57639d418b109b89bf648 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sat, 19 Dec 2020 19:55:12 +0200 Subject: [PATCH 066/103] Updated project structure and some file names. Restored `requirements.txt` Updated `setup.py` to include new repository url and contact details. Moved the rtsp code from `record` to `stream`. Updated project structure to make it more readable and developer friendly - moved mixins to the `mixins` package, moved handlers to the `handlers` package. Moved files not belonging to anything in particular to the `util` package. Updated `camera` class to also defer login call. Deleted unused files like `config_handler`. --- README.md | 6 +-- examples/basic_usage.py | 11 ++++ reolinkapi/__init__.py | 2 +- reolinkapi/camera.py | 22 ++++++-- reolinkapi/config_handler.py | 17 ------ reolinkapi/handlers/__init__.py | 0 reolinkapi/{ => handlers}/api_handler.py | 33 ++++++------ .../rest_handler.py} | 0 reolinkapi/mixins/__init__.py | 0 reolinkapi/{ => mixins}/alarm.py | 0 reolinkapi/{ => mixins}/device.py | 0 reolinkapi/{ => mixins}/display.py | 0 reolinkapi/{ => mixins}/download.py | 0 reolinkapi/{ => mixins}/image.py | 0 reolinkapi/{ => mixins}/motion.py | 0 reolinkapi/{ => mixins}/network.py | 0 reolinkapi/{ => mixins}/ptz.py | 0 reolinkapi/{recording.py => mixins/record.py} | 54 ++----------------- reolinkapi/mixins/stream.py | 52 ++++++++++++++++++ reolinkapi/{ => mixins}/system.py | 0 reolinkapi/{ => mixins}/user.py | 0 reolinkapi/{ => mixins}/zoom.py | 0 reolinkapi/utils/__init__.py | 0 reolinkapi/{ => utils}/rtsp_client.py | 3 +- reolinkapi/{ => utils}/util.py | 0 requirements.txt | 6 +++ setup.py | 6 +-- 27 files changed, 117 insertions(+), 95 deletions(-) create mode 100644 examples/basic_usage.py delete mode 100644 reolinkapi/config_handler.py create mode 100644 reolinkapi/handlers/__init__.py rename reolinkapi/{ => handlers}/api_handler.py (84%) rename reolinkapi/{resthandle.py => handlers/rest_handler.py} (100%) create mode 100644 reolinkapi/mixins/__init__.py rename reolinkapi/{ => mixins}/alarm.py (100%) rename reolinkapi/{ => mixins}/device.py (100%) rename reolinkapi/{ => mixins}/display.py (100%) rename reolinkapi/{ => mixins}/download.py (100%) rename reolinkapi/{ => mixins}/image.py (100%) rename reolinkapi/{ => mixins}/motion.py (100%) rename reolinkapi/{ => mixins}/network.py (100%) rename reolinkapi/{ => mixins}/ptz.py (100%) rename reolinkapi/{recording.py => mixins/record.py} (57%) create mode 100644 reolinkapi/mixins/stream.py rename reolinkapi/{ => mixins}/system.py (100%) rename reolinkapi/{ => mixins}/user.py (100%) rename reolinkapi/{ => mixins}/zoom.py (100%) create mode 100644 reolinkapi/utils/__init__.py rename reolinkapi/{ => utils}/rtsp_client.py (97%) rename reolinkapi/{ => utils}/util.py (100%) create mode 100644 requirements.txt 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/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/reolinkapi/__init__.py b/reolinkapi/__init__.py index e623a67..0a886c6 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ -from .api_handler import APIHandler +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 index 47db97e..98c208b 100644 --- a/reolinkapi/camera.py +++ b/reolinkapi/camera.py @@ -1,19 +1,31 @@ -from .api_handler import APIHandler +from reolinkapi.handlers.api_handler import APIHandler class Camera(APIHandler): - def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = False): + 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) + APIHandler.__init__(self, ip, username, password, https=https, **kwargs) # Normal call without proxy: # APIHandler.__init__(self, ip, username, password) @@ -21,4 +33,6 @@ def __init__(self, ip: str, username: str = "admin", password: str = "", https: self.ip = ip self.username = username self.password = password - super().login() + + if not defer_login: + super().login() diff --git a/reolinkapi/config_handler.py b/reolinkapi/config_handler.py deleted file mode 100644 index a1c08ec..0000000 --- a/reolinkapi/config_handler.py +++ /dev/null @@ -1,17 +0,0 @@ -import io -import yaml -from typing import Optional, Dict - - -class ConfigHandler: - camera_settings = {} - - @staticmethod - def load() -> Optional[Dict]: - 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/reolinkapi/handlers/__init__.py b/reolinkapi/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/api_handler.py b/reolinkapi/handlers/api_handler.py similarity index 84% rename from reolinkapi/api_handler.py rename to reolinkapi/handlers/api_handler.py index 3ceae88..46d4637 100644 --- a/reolinkapi/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -1,18 +1,19 @@ import requests from typing import Dict, List, Optional, Union -from reolinkapi.alarm import AlarmAPIMixin -from reolinkapi.device import DeviceAPIMixin -from reolinkapi.display import DisplayAPIMixin -from reolinkapi.download import DownloadAPIMixin -from reolinkapi.image import ImageAPIMixin -from reolinkapi.motion import MotionAPIMixin -from reolinkapi.network import NetworkAPIMixin -from reolinkapi.ptz import PtzAPIMixin -from reolinkapi.recording import RecordingAPIMixin -from reolinkapi.resthandle import Request -from reolinkapi.system import SystemAPIMixin -from reolinkapi.user import UserAPIMixin -from reolinkapi.zoom import ZoomAPIMixin +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(AlarmAPIMixin, @@ -23,10 +24,11 @@ class APIHandler(AlarmAPIMixin, MotionAPIMixin, NetworkAPIMixin, PtzAPIMixin, - RecordingAPIMixin, + RecordAPIMixin, SystemAPIMixin, UserAPIMixin, - ZoomAPIMixin): + ZoomAPIMixin, + StreamAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. @@ -42,6 +44,7 @@ def __init__(self, ip: str, username: str, password: str, https: bool = False, * :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 diff --git a/reolinkapi/resthandle.py b/reolinkapi/handlers/rest_handler.py similarity index 100% rename from reolinkapi/resthandle.py rename to reolinkapi/handlers/rest_handler.py diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/alarm.py b/reolinkapi/mixins/alarm.py similarity index 100% rename from reolinkapi/alarm.py rename to reolinkapi/mixins/alarm.py diff --git a/reolinkapi/device.py b/reolinkapi/mixins/device.py similarity index 100% rename from reolinkapi/device.py rename to reolinkapi/mixins/device.py diff --git a/reolinkapi/display.py b/reolinkapi/mixins/display.py similarity index 100% rename from reolinkapi/display.py rename to reolinkapi/mixins/display.py diff --git a/reolinkapi/download.py b/reolinkapi/mixins/download.py similarity index 100% rename from reolinkapi/download.py rename to reolinkapi/mixins/download.py diff --git a/reolinkapi/image.py b/reolinkapi/mixins/image.py similarity index 100% rename from reolinkapi/image.py rename to reolinkapi/mixins/image.py diff --git a/reolinkapi/motion.py b/reolinkapi/mixins/motion.py similarity index 100% rename from reolinkapi/motion.py rename to reolinkapi/mixins/motion.py diff --git a/reolinkapi/network.py b/reolinkapi/mixins/network.py similarity index 100% rename from reolinkapi/network.py rename to reolinkapi/mixins/network.py diff --git a/reolinkapi/ptz.py b/reolinkapi/mixins/ptz.py similarity index 100% rename from reolinkapi/ptz.py rename to reolinkapi/mixins/ptz.py diff --git a/reolinkapi/recording.py b/reolinkapi/mixins/record.py similarity index 57% rename from reolinkapi/recording.py rename to reolinkapi/mixins/record.py index 41284f2..375b5ca 100644 --- a/reolinkapi/recording.py +++ b/reolinkapi/mixins/record.py @@ -1,15 +1,8 @@ -import requests -import random -import string -from urllib import parse -from io import BytesIO -from typing import Dict, Any, Optional -from PIL.Image import Image, open as open_image -from reolinkapi.rtsp_client import RtspClient +from typing import Dict -class RecordingAPIMixin: - """API calls for recording/streaming image or video.""" +class RecordAPIMixin: + """API calls for the recording settings""" def get_recording_encoding(self) -> Dict: """ @@ -77,44 +70,3 @@ def set_recording_encoding(self, } ] return self._execute_command('SetEnc', body) - - ########### - # RTSP Stream - ########### - 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/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/reolinkapi/system.py b/reolinkapi/mixins/system.py similarity index 100% rename from reolinkapi/system.py rename to reolinkapi/mixins/system.py diff --git a/reolinkapi/user.py b/reolinkapi/mixins/user.py similarity index 100% rename from reolinkapi/user.py rename to reolinkapi/mixins/user.py diff --git a/reolinkapi/zoom.py b/reolinkapi/mixins/zoom.py similarity index 100% rename from reolinkapi/zoom.py rename to reolinkapi/mixins/zoom.py diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/rtsp_client.py b/reolinkapi/utils/rtsp_client.py similarity index 97% rename from reolinkapi/rtsp_client.py rename to reolinkapi/utils/rtsp_client.py index 0c1db0e..e260a74 100644 --- a/reolinkapi/rtsp_client.py +++ b/reolinkapi/utils/rtsp_client.py @@ -2,11 +2,12 @@ from threading import ThreadError from typing import Any import cv2 -from reolinkapi.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 diff --git a/reolinkapi/util.py b/reolinkapi/utils/util.py similarity index 100% rename from reolinkapi/util.py rename to reolinkapi/utils/util.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a8aabcd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +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 500a40f..3764023 100644 --- a/setup.py +++ b/setup.py @@ -20,9 +20,9 @@ def find_version(*file_paths): # Package meta-data. NAME = 'reolinkapi' -DESCRIPTION = 'Reolink Camera API written in Python 3.6' -URL = 'https://github.com/Benehiko/ReolinkCameraAPI' -AUTHOR_EMAIL = '' +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 = [ From 32e602571d8a67de15f1a82cba73d93d12c2e6d1 Mon Sep 17 00:00:00 2001 From: Alano Date: Sat, 19 Dec 2020 23:02:58 +0200 Subject: [PATCH 067/103] updated readme & requirements file `requirements.txt` reads directly from setup.py which means it will install all the packages listed from setup.py. This is a small work around to prevent managing two different "sources of truth". --- .gitignore | 1 + README.md | 10 ++++++---- requirements.txt | 7 +------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 2836d9e..c0c4d43 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ docs/_build/ target/ .idea/ +venv/ \ No newline at end of file diff --git a/README.md b/README.md index 4fcdd37..802c11d 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,13 @@ See the `examples` directory. ### Using the library as a Python Module -Install the package via Pip +Install the package via PyPi - pip install reolink-api==0.0.5 + pip install reolinkapi Install from GitHub - pip install git+https://github.com/ReolinkCameraAPI/reolink-python-api.git + pip install git+https://github.com/ReolinkCameraAPI/reolinkapipy.git ## Contributors @@ -65,7 +65,9 @@ Get the Restful API calls by looking through the HTTP Requests made in the camer #### Step 2 -Fork the repository and make your changes. +- Fork the repository +- pip install -r requirements.txt +- Make your changes #### Step 3 diff --git a/requirements.txt b/requirements.txt index a8aabcd..945c9b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1 @@ -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 +. \ No newline at end of file From e77b267b1f86ca7d145f0c08f5fe3649fb4a8198 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Tue, 12 Jan 2021 12:31:11 +0200 Subject: [PATCH 068/103] Update README.md added supported cameras --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 802c11d..bb14a0b 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,5 @@ do not work and is not supported here. - RLC-420-5MP - RLC-410-5MP - RLC-520 +- C1-Pro +- D400 From ed16b76cda04ad33eded0896305afa4c277dff8e Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 18 Feb 2021 22:53:51 +1100 Subject: [PATCH 069/103] Fix HTTPS video download Adding missing `verify=False` parameter to fix the error: `(Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: EE certificate key too weak (_ssl.c:1124)')))` Also, I would suggest using HTTPS by default, otherwise you are leaking the username and password to everyone on the network. --- reolinkapi/handlers/api_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 46d4637..0b8abab 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -124,7 +124,7 @@ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) 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: + with requests.get(self.url, params=params, stream=True, verify=False, timeout=(1, None)) as req: if req.status_code == 200: with open(tgt_filepath, 'wb') as f: f.write(req.content) From 8cd34053783ba2bd814fffdaabf25259812cbd33 Mon Sep 17 00:00:00 2001 From: jdiamond Date: Tue, 30 Mar 2021 23:04:18 +0000 Subject: [PATCH 070/103] Move streaming dependancies to their own "extra" --- reolinkapi/mixins/stream.py | 101 ++++++++++++++++++++---------------- setup.py | 13 +++-- 2 files changed, 65 insertions(+), 49 deletions(-) diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 5d6e419..4869247 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -5,48 +5,59 @@ 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 + +try: + 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 +except ImportError: + 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: + raise ImportError('''open_video_stream requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"''') + + def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional['Image']: + raise ImportError('''open_video_stream requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"''') diff --git a/setup.py b/setup.py index 3764023..8166180 100644 --- a/setup.py +++ b/setup.py @@ -26,13 +26,17 @@ def find_version(*file_paths): 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', ] +EXTRAS_REQUIRE = { + 'streaming': [ + 'numpy==1.19.4', + 'opencv-python==4.4.0.46', + 'Pillow==8.0.1', + ], +} here = os.path.abspath(os.path.dirname(__file__)) @@ -53,5 +57,6 @@ def find_version(*file_paths): url=URL, license=LICENSE, install_requires=INSTALL_REQUIRES, - packages=find_packages(exclude=['examples', 'tests']) + packages=find_packages(exclude=['examples', 'tests']), + extras_require=EXTRAS_REQUIRE ) From 98acbdec1d2a653c3a91cc38ecd05f12a806dc8d Mon Sep 17 00:00:00 2001 From: jdiamond Date: Wed, 31 Mar 2021 00:14:58 +0000 Subject: [PATCH 071/103] Updated README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index bb14a0b..7c1653f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ Install the package via PyPi Install from GitHub pip install git+https://github.com/ReolinkCameraAPI/reolinkapipy.git + +If you want to include the video streaming functionality you need to include the streaming "extra" dependencies + + pip install 'reolinkapi[streaming]' ## Contributors From ac5ee722611a69eadf17d72b64d8214ce0e72d69 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Fri, 2 Apr 2021 08:02:42 +0200 Subject: [PATCH 072/103] update: bump version to 0.1.3 --- reolinkapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 0a886c6..111cb34 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.1.2" +__version__ = "0.1.3" From b80b29f01342fad9d4b8a06e3d2daaa0133825c8 Mon Sep 17 00:00:00 2001 From: Stephen Golub Date: Tue, 24 Aug 2021 10:52:51 -0500 Subject: [PATCH 073/103] Add ability to toggle watermark on or off --- reolinkapi/mixins/display.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/reolinkapi/mixins/display.py b/reolinkapi/mixins/display.py index 5c4c48c..ded8636 100644 --- a/reolinkapi/mixins/display.py +++ b/reolinkapi/mixins/display.py @@ -24,7 +24,7 @@ def get_mask(self) -> Dict: 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: + osd_time_pos: str = "Lower Right", osd_watermark_enabled: bool = 0) -> bool: """ Set OSD :param bg_color: bool @@ -47,7 +47,8 @@ def set_osd(self, bg_color: bool = 0, channel: float = 0, osd_channel_enabled: b "enable": osd_channel_enabled, "name": osd_channel_name, "pos": osd_channel_pos }, - "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos} + "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos}, + "watermark": osd_watermark_enabled, }}}] r_data = self._execute_command('SetOsd', body)[0] if r_data["value"]["rspCode"] == 200: From 04d5d4a7ae4dbe68be15b73be008119d8130d03b Mon Sep 17 00:00:00 2001 From: Stephen Golub Date: Tue, 24 Aug 2021 10:53:06 -0500 Subject: [PATCH 074/103] Fix error response handling for display --- reolinkapi/mixins/display.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reolinkapi/mixins/display.py b/reolinkapi/mixins/display.py index ded8636..c44c04b 100644 --- a/reolinkapi/mixins/display.py +++ b/reolinkapi/mixins/display.py @@ -51,7 +51,7 @@ def set_osd(self, bg_color: bool = 0, channel: float = 0, osd_channel_enabled: b "watermark": osd_watermark_enabled, }}}] r_data = self._execute_command('SetOsd', body)[0] - if r_data["value"]["rspCode"] == 200: + if 'value' in r_data and r_data["value"]["rspCode"] == 200: return True - print("Could not set OSD. Camera responded with status:", r_data["value"]) + print("Could not set OSD. Camera responded with status:", r_data["error"]) return False From bf41e79c62fab17e3f6bbe83bfc37dfbce36bf63 Mon Sep 17 00:00:00 2001 From: Christian Pellegrin Date: Fri, 17 Sep 2021 16:42:34 +0100 Subject: [PATCH 075/103] Fix importing of choices function from random library. --- reolinkapi/mixins/stream.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 4869247..636856c 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -1,5 +1,5 @@ import string -from random import random +from random import choices from typing import Any, Optional from urllib import parse from io import BytesIO @@ -36,7 +36,7 @@ def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: data = { 'cmd': 'Snap', 'channel': 0, - 'rs': ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)), + 'rs': ''.join(choices(string.ascii_uppercase + string.digits, k=10)), 'user': self.username, 'password': self.password, } From 8f23966289e9f346de9cce748a8e772da02a8728 Mon Sep 17 00:00:00 2001 From: kennybradley Date: Fri, 22 Oct 2021 23:54:35 -0700 Subject: [PATCH 076/103] Update camera.py Adding profile so sub can be used. This is useful for low power devices like raspberry pi. --- reolinkapi/camera.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py index 98c208b..7371b20 100644 --- a/reolinkapi/camera.py +++ b/reolinkapi/camera.py @@ -8,6 +8,7 @@ def __init__(self, ip: str, password: str = "", https: bool = False, defer_login: bool = False, + profile: str = "main", **kwargs): """ Initialise the Camera object by passing the ip address. @@ -23,6 +24,9 @@ def __init__(self, ip: str, eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...} More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679 """ + if profile not in ["main", "sub"]: + raise Exception("Profile argument must be either \"main\" or \"sub\"") + # 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) @@ -33,6 +37,7 @@ def __init__(self, ip: str, self.ip = ip self.username = username self.password = password + self.profile = profile if not defer_login: super().login() From 63f2cd78c927aa06064860442305dd86c08bc7fc Mon Sep 17 00:00:00 2001 From: kennybradley Date: Fri, 22 Oct 2021 23:55:16 -0700 Subject: [PATCH 077/103] Update stream.py Using the added profile argument. --- reolinkapi/mixins/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 636856c..6798a42 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -23,7 +23,7 @@ def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any: :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) + ip=self.ip, username=self.username, password=self.password, profile=self.profile, proxies=proxies, callback=callback) return rtsp_client.open_stream() def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: From 936c43b6fa94a03fdf978ee78b961b629cc7d7e9 Mon Sep 17 00:00:00 2001 From: ammar Date: Fri, 3 Dec 2021 16:42:52 -0800 Subject: [PATCH 078/103] Add proxy arg to get for downloading files --- reolinkapi/handlers/api_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 0b8abab..501ceb7 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -124,7 +124,7 @@ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) 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, verify=False, timeout=(1, None)) as req: + with requests.get(self.url, params=params, stream=True, verify=False, timeout=(1, None), proxies=Request.proxies) as req: if req.status_code == 200: with open(tgt_filepath, 'wb') as f: f.write(req.content) From d59cefff525fdb1424343cfd4b4838738ba449e1 Mon Sep 17 00:00:00 2001 From: deadc0de6 Date: Sun, 31 Jul 2022 14:46:06 +0200 Subject: [PATCH 079/103] fix KeyError: value --- reolinkapi/mixins/motion.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py index b95746d..73d3437 100644 --- a/reolinkapi/mixins/motion.py +++ b/reolinkapi/mixins/motion.py @@ -46,7 +46,12 @@ def get_motion_files(self, start: dt, end: dt = dt.now(), body = [{"cmd": "Search", "action": 1, "param": search_params}] resp = self._execute_command('Search', body)[0] - result = resp['value']['SearchResult'] + if 'value' not in resp: + return [] + values = resp['value'] + if 'SearchResult' not in values: + return [] + result = values['SearchResult'] files = result.get('File', []) if len(files) > 0: # Begin processing files From 37dd49f93683eb82552928d5d24a08f28b757b8f Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Sat, 20 Aug 2022 12:33:39 +0200 Subject: [PATCH 080/103] chore: bump version to 0.1.4 --- reolinkapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 111cb34..6ac141b 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.1.3" +__version__ = "0.1.4" From a61e86cfa4c69d59547247823bc8e4a42376239e Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Sat, 20 Aug 2022 12:43:57 +0200 Subject: [PATCH 081/103] chore: bump version to 0.1.5 --- reolinkapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 6ac141b..99bb450 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.1.4" +__version__ = "0.1.5" From 7999ce810838f70f76de724e6d75b4fb1ec8a9de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Borz=C3=AC?= Date: Sun, 21 Aug 2022 19:22:15 +0200 Subject: [PATCH 082/103] chore: add RLC-510A in supported cameras list --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7c1653f..862cb9d 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ do not work and is not supported here. - RLC-423 - RLC-420-5MP - RLC-410-5MP +- RLC-510A - RLC-520 - C1-Pro - D400 From c16189a68b240db9da06532cbf58d5626637f415 Mon Sep 17 00:00:00 2001 From: Hala Khodr Date: Tue, 28 Nov 2023 12:07:44 +0100 Subject: [PATCH 083/103] add nvrdownload command --- reolinkapi/handlers/api_handler.py | 4 ++- reolinkapi/mixins/nvrdownload.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 reolinkapi/mixins/nvrdownload.py diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 501ceb7..b9efe58 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -14,6 +14,7 @@ from reolinkapi.mixins.system import SystemAPIMixin from reolinkapi.mixins.user import UserAPIMixin from reolinkapi.mixins.zoom import ZoomAPIMixin +from reolinkapi.mixins.nvrdownload import NvrDownloadAPIMixin class APIHandler(AlarmAPIMixin, @@ -28,7 +29,8 @@ class APIHandler(AlarmAPIMixin, SystemAPIMixin, UserAPIMixin, ZoomAPIMixin, - StreamAPIMixin): + StreamAPIMixin, + NvrDownloadAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. diff --git a/reolinkapi/mixins/nvrdownload.py b/reolinkapi/mixins/nvrdownload.py new file mode 100644 index 0000000..911182a --- /dev/null +++ b/reolinkapi/mixins/nvrdownload.py @@ -0,0 +1,51 @@ +from datetime import datetime as dt + +class NvrDownloadAPIMixin: + """API calls for NvrDownload.""" + def get_playback_files(self, start: dt, end: dt = dt.now(), channel: int = 0, + streamtype: str = 'sub'): + """ + Get the filenames of the videos for the time range provided. + + Args: + start: the starting time range to examine + end: the end time of the time range to examine + channel: which channel to download from + streamtype: 'main' or 'sub' - the stream to examine + :return: response json + """ + search_params = { + 'NvrDownload': { + 'channel': channel, + 'iLogicChannel': 0, + 'streamType': streamtype, + '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": "NvrDownload", "action": 1, "param": search_params}] + + resp = self._execute_command('NvrDownload', body)[0] + if 'value' not in resp: + return [] + values = resp['value'] + if 'fileList' not in values: + return [] + files = values['fileList'] + if len(files) > 0: + return [file['fileName'] for file in files] + return [] From 9b66cd4ff8260755b2d7396951ce0345f0738d9a Mon Sep 17 00:00:00 2001 From: Hala Khodr Date: Tue, 28 Nov 2023 12:21:22 +0100 Subject: [PATCH 084/103] add example of download playback videos --- examples/download_playback_video.py | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 examples/download_playback_video.py diff --git a/examples/download_playback_video.py b/examples/download_playback_video.py new file mode 100644 index 0000000..85f8202 --- /dev/null +++ b/examples/download_playback_video.py @@ -0,0 +1,46 @@ +"""Downloads a video from camera from start to end time.""" +import os +from configparser import RawConfigParser +from datetime import datetime as dt, timedelta +from reolinkapi import Camera +import requests +import pandas as pd + +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('camera.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(minutes=10) +end = dt.now() - timedelta(minutes=9) +channel = 0 + +files = cam.get_playback_files(start=start, end=end, channel= channel) +print(files) +dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads') +for fname in files: + print(fname) + # Download the mp4 + cam.get_file(fname, output_path=os.path.join(dl_dir, fname)) From 4c8d93c157a677f1ee31273a37baacffa5f711ea Mon Sep 17 00:00:00 2001 From: Hala Khodr Date: Tue, 28 Nov 2023 15:08:56 +0100 Subject: [PATCH 085/103] add try catch block --- reolinkapi/mixins/nvrdownload.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/reolinkapi/mixins/nvrdownload.py b/reolinkapi/mixins/nvrdownload.py index 911182a..c4e3051 100644 --- a/reolinkapi/mixins/nvrdownload.py +++ b/reolinkapi/mixins/nvrdownload.py @@ -38,8 +38,11 @@ def get_playback_files(self, start: dt, end: dt = dt.now(), channel: int = 0, } } body = [{"cmd": "NvrDownload", "action": 1, "param": search_params}] - - resp = self._execute_command('NvrDownload', body)[0] + try: + resp = self._execute_command('NvrDownload', body)[0] + except Exception as e: + print(f"Error: {e}") + return [] if 'value' not in resp: return [] values = resp['value'] From 2eab3804d510f35918e5752a23a1b50be6c78035 Mon Sep 17 00:00:00 2001 From: car-haj <157540280+car-haj@users.noreply.github.com> Date: Wed, 24 Jan 2024 11:37:17 +0100 Subject: [PATCH 086/103] feat: add presets and calibration related methods, add to docs, add example jsons (#74) add get methods for presets, calibration status and action for calibration, amend docs, add examples, add E1 Zoom to list of supported cameras --- README.md | 5 +- examples/response/GetPtzCheckState.json | 19 + examples/response/GetPtzPresets.json | 926 ++++++++++++++++++++++++ examples/response/PtzCheck.json | 1 + reolinkapi/mixins/ptz.py | 33 + 5 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 examples/response/GetPtzCheckState.json create mode 100644 examples/response/GetPtzPresets.json create mode 100644 examples/response/PtzCheck.json diff --git a/README.md b/README.md index 862cb9d..1b9c651 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ GET: - [X] User -> Add User - [X] User -> Manage User - [X] Device -> HDD/SD Card +- [x] PTZ -> Presets, Calibration Status - [ ] Zoom - [ ] Focus - [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) @@ -134,7 +135,7 @@ SET: - [X] User -> Add User - [X] User -> Manage User - [X] Device -> HDD/SD Card (Format) -- [x] PTZ +- [x] PTZ (including calibrate) - [x] Zoom - [x] Focus - [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) @@ -153,3 +154,5 @@ do not work and is not supported here. - RLC-520 - C1-Pro - D400 +- E1 Zoom + diff --git a/examples/response/GetPtzCheckState.json b/examples/response/GetPtzCheckState.json new file mode 100644 index 0000000..55386ec --- /dev/null +++ b/examples/response/GetPtzCheckState.json @@ -0,0 +1,19 @@ +[ + { + "cmd": "GetPtzCheckState", + "code": 0, + "initial": { + "PtzCheckState": 2 + }, + "range": { + "PtzCheckState": [ + 0, + 1, + 2 + ] + }, + "value": { + "PtzCheckState": 2 + } + } +] diff --git a/examples/response/GetPtzPresets.json b/examples/response/GetPtzPresets.json new file mode 100644 index 0000000..b89dad3 --- /dev/null +++ b/examples/response/GetPtzPresets.json @@ -0,0 +1,926 @@ +[ + { + "cmd": "GetPtzPreset", + "code": 0, + "initial": { + "PtzPreset": [ + { + "channel": 0, + "enable": 1, + "id": 0, + "imgName": "preset_00", + "name": "namepos0" + }, + { + "channel": 0, + "enable": 1, + "id": 1, + "imgName": "preset_01", + "name": "othernamepos1" + }, + { + "channel": 0, + "enable": 0, + "id": 2, + "imgName": "", + "name": "pos3" + }, + { + "channel": 0, + "enable": 0, + "id": 3, + "imgName": "", + "name": "pos4" + }, + { + "channel": 0, + "enable": 0, + "id": 4, + "imgName": "", + "name": "pos5" + }, + { + "channel": 0, + "enable": 0, + "id": 5, + "imgName": "", + "name": "pos6" + }, + { + "channel": 0, + "enable": 0, + "id": 6, + "imgName": "", + "name": "pos7" + }, + { + "channel": 0, + "enable": 0, + "id": 7, + "imgName": "", + "name": "pos8" + }, + { + "channel": 0, + "enable": 0, + "id": 8, + "imgName": "", + "name": "pos9" + }, + { + "channel": 0, + "enable": 0, + "id": 9, + "imgName": "", + "name": "pos10" + }, + { + "channel": 0, + "enable": 0, + "id": 10, + "imgName": "", + "name": "pos11" + }, + { + "channel": 0, + "enable": 0, + "id": 11, + "imgName": "", + "name": "pos12" + }, + { + "channel": 0, + "enable": 0, + "id": 12, + "imgName": "", + "name": "pos13" + }, + { + "channel": 0, + "enable": 0, + "id": 13, + "imgName": "", + "name": "pos14" + }, + { + "channel": 0, + "enable": 0, + "id": 14, + "imgName": "", + "name": "pos15" + }, + { + "channel": 0, + "enable": 0, + "id": 15, + "imgName": "", + "name": "pos16" + }, + { + "channel": 0, + "enable": 0, + "id": 16, + "imgName": "", + "name": "pos17" + }, + { + "channel": 0, + "enable": 0, + "id": 17, + "imgName": "", + "name": "pos18" + }, + { + "channel": 0, + "enable": 0, + "id": 18, + "imgName": "", + "name": "pos19" + }, + { + "channel": 0, + "enable": 0, + "id": 19, + "imgName": "", + "name": "pos20" + }, + { + "channel": 0, + "enable": 0, + "id": 20, + "imgName": "", + "name": "pos21" + }, + { + "channel": 0, + "enable": 0, + "id": 21, + "imgName": "", + "name": "pos22" + }, + { + "channel": 0, + "enable": 0, + "id": 22, + "imgName": "", + "name": "pos23" + }, + { + "channel": 0, + "enable": 0, + "id": 23, + "imgName": "", + "name": "pos24" + }, + { + "channel": 0, + "enable": 0, + "id": 24, + "imgName": "", + "name": "pos25" + }, + { + "channel": 0, + "enable": 0, + "id": 25, + "imgName": "", + "name": "pos26" + }, + { + "channel": 0, + "enable": 0, + "id": 26, + "imgName": "", + "name": "pos27" + }, + { + "channel": 0, + "enable": 0, + "id": 27, + "imgName": "", + "name": "pos28" + }, + { + "channel": 0, + "enable": 0, + "id": 28, + "imgName": "", + "name": "pos29" + }, + { + "channel": 0, + "enable": 0, + "id": 29, + "imgName": "", + "name": "pos30" + }, + { + "channel": 0, + "enable": 0, + "id": 30, + "imgName": "", + "name": "pos31" + }, + { + "channel": 0, + "enable": 0, + "id": 31, + "imgName": "", + "name": "pos32" + }, + { + "channel": 0, + "enable": 0, + "id": 32, + "imgName": "", + "name": "pos33" + }, + { + "channel": 0, + "enable": 0, + "id": 33, + "imgName": "", + "name": "pos34" + }, + { + "channel": 0, + "enable": 0, + "id": 34, + "imgName": "", + "name": "pos35" + }, + { + "channel": 0, + "enable": 0, + "id": 35, + "imgName": "", + "name": "pos36" + }, + { + "channel": 0, + "enable": 0, + "id": 36, + "imgName": "", + "name": "pos37" + }, + { + "channel": 0, + "enable": 0, + "id": 37, + "imgName": "", + "name": "pos38" + }, + { + "channel": 0, + "enable": 0, + "id": 38, + "imgName": "", + "name": "pos39" + }, + { + "channel": 0, + "enable": 0, + "id": 39, + "imgName": "", + "name": "pos40" + }, + { + "channel": 0, + "enable": 0, + "id": 40, + "imgName": "", + "name": "pos41" + }, + { + "channel": 0, + "enable": 0, + "id": 41, + "imgName": "", + "name": "pos42" + }, + { + "channel": 0, + "enable": 0, + "id": 42, + "imgName": "", + "name": "pos43" + }, + { + "channel": 0, + "enable": 0, + "id": 43, + "imgName": "", + "name": "pos44" + }, + { + "channel": 0, + "enable": 0, + "id": 44, + "imgName": "", + "name": "pos45" + }, + { + "channel": 0, + "enable": 0, + "id": 45, + "imgName": "", + "name": "pos46" + }, + { + "channel": 0, + "enable": 0, + "id": 46, + "imgName": "", + "name": "pos47" + }, + { + "channel": 0, + "enable": 0, + "id": 47, + "imgName": "", + "name": "pos48" + }, + { + "channel": 0, + "enable": 0, + "id": 48, + "imgName": "", + "name": "pos49" + }, + { + "channel": 0, + "enable": 0, + "id": 49, + "imgName": "", + "name": "pos50" + }, + { + "channel": 0, + "enable": 0, + "id": 50, + "imgName": "", + "name": "pos51" + }, + { + "channel": 0, + "enable": 0, + "id": 51, + "imgName": "", + "name": "pos52" + }, + { + "channel": 0, + "enable": 0, + "id": 52, + "imgName": "", + "name": "pos53" + }, + { + "channel": 0, + "enable": 0, + "id": 53, + "imgName": "", + "name": "pos54" + }, + { + "channel": 0, + "enable": 0, + "id": 54, + "imgName": "", + "name": "pos55" + }, + { + "channel": 0, + "enable": 0, + "id": 55, + "imgName": "", + "name": "pos56" + }, + { + "channel": 0, + "enable": 0, + "id": 56, + "imgName": "", + "name": "pos57" + }, + { + "channel": 0, + "enable": 0, + "id": 57, + "imgName": "", + "name": "pos58" + }, + { + "channel": 0, + "enable": 0, + "id": 58, + "imgName": "", + "name": "pos59" + }, + { + "channel": 0, + "enable": 0, + "id": 59, + "imgName": "", + "name": "pos60" + }, + { + "channel": 0, + "enable": 0, + "id": 60, + "imgName": "", + "name": "pos61" + }, + { + "channel": 0, + "enable": 0, + "id": 61, + "imgName": "", + "name": "pos62" + }, + { + "channel": 0, + "enable": 0, + "id": 62, + "imgName": "", + "name": "pos63" + }, + { + "channel": 0, + "enable": 0, + "id": 63, + "imgName": "", + "name": "pos64" + } + ] + }, + "range": { + "PtzPreset": { + "channel": 0, + "enable": "boolean", + "id": { + "max": 64, + "min": 1 + }, + "imgName": { + "maxLen": 31 + }, + "name": { + "maxLen": 31 + } + } + }, + "value": { + "PtzPreset": [ + { + "channel": 0, + "enable": 1, + "id": 0, + "imgName": "preset_00", + "name": "0" + }, + { + "channel": 0, + "enable": 1, + "id": 1, + "imgName": "preset_01", + "name": "1" + }, + { + "channel": 0, + "enable": 0, + "id": 2, + "imgName": "", + "name": "pos3" + }, + { + "channel": 0, + "enable": 0, + "id": 3, + "imgName": "", + "name": "pos4" + }, + { + "channel": 0, + "enable": 0, + "id": 4, + "imgName": "", + "name": "pos5" + }, + { + "channel": 0, + "enable": 0, + "id": 5, + "imgName": "", + "name": "pos6" + }, + { + "channel": 0, + "enable": 0, + "id": 6, + "imgName": "", + "name": "pos7" + }, + { + "channel": 0, + "enable": 0, + "id": 7, + "imgName": "", + "name": "pos8" + }, + { + "channel": 0, + "enable": 0, + "id": 8, + "imgName": "", + "name": "pos9" + }, + { + "channel": 0, + "enable": 0, + "id": 9, + "imgName": "", + "name": "pos10" + }, + { + "channel": 0, + "enable": 0, + "id": 10, + "imgName": "", + "name": "pos11" + }, + { + "channel": 0, + "enable": 0, + "id": 11, + "imgName": "", + "name": "pos12" + }, + { + "channel": 0, + "enable": 0, + "id": 12, + "imgName": "", + "name": "pos13" + }, + { + "channel": 0, + "enable": 0, + "id": 13, + "imgName": "", + "name": "pos14" + }, + { + "channel": 0, + "enable": 0, + "id": 14, + "imgName": "", + "name": "pos15" + }, + { + "channel": 0, + "enable": 0, + "id": 15, + "imgName": "", + "name": "pos16" + }, + { + "channel": 0, + "enable": 0, + "id": 16, + "imgName": "", + "name": "pos17" + }, + { + "channel": 0, + "enable": 0, + "id": 17, + "imgName": "", + "name": "pos18" + }, + { + "channel": 0, + "enable": 0, + "id": 18, + "imgName": "", + "name": "pos19" + }, + { + "channel": 0, + "enable": 0, + "id": 19, + "imgName": "", + "name": "pos20" + }, + { + "channel": 0, + "enable": 0, + "id": 20, + "imgName": "", + "name": "pos21" + }, + { + "channel": 0, + "enable": 0, + "id": 21, + "imgName": "", + "name": "pos22" + }, + { + "channel": 0, + "enable": 0, + "id": 22, + "imgName": "", + "name": "pos23" + }, + { + "channel": 0, + "enable": 0, + "id": 23, + "imgName": "", + "name": "pos24" + }, + { + "channel": 0, + "enable": 0, + "id": 24, + "imgName": "", + "name": "pos25" + }, + { + "channel": 0, + "enable": 0, + "id": 25, + "imgName": "", + "name": "pos26" + }, + { + "channel": 0, + "enable": 0, + "id": 26, + "imgName": "", + "name": "pos27" + }, + { + "channel": 0, + "enable": 0, + "id": 27, + "imgName": "", + "name": "pos28" + }, + { + "channel": 0, + "enable": 0, + "id": 28, + "imgName": "", + "name": "pos29" + }, + { + "channel": 0, + "enable": 0, + "id": 29, + "imgName": "", + "name": "pos30" + }, + { + "channel": 0, + "enable": 0, + "id": 30, + "imgName": "", + "name": "pos31" + }, + { + "channel": 0, + "enable": 0, + "id": 31, + "imgName": "", + "name": "pos32" + }, + { + "channel": 0, + "enable": 0, + "id": 32, + "imgName": "", + "name": "pos33" + }, + { + "channel": 0, + "enable": 0, + "id": 33, + "imgName": "", + "name": "pos34" + }, + { + "channel": 0, + "enable": 0, + "id": 34, + "imgName": "", + "name": "pos35" + }, + { + "channel": 0, + "enable": 0, + "id": 35, + "imgName": "", + "name": "pos36" + }, + { + "channel": 0, + "enable": 0, + "id": 36, + "imgName": "", + "name": "pos37" + }, + { + "channel": 0, + "enable": 0, + "id": 37, + "imgName": "", + "name": "pos38" + }, + { + "channel": 0, + "enable": 0, + "id": 38, + "imgName": "", + "name": "pos39" + }, + { + "channel": 0, + "enable": 0, + "id": 39, + "imgName": "", + "name": "pos40" + }, + { + "channel": 0, + "enable": 0, + "id": 40, + "imgName": "", + "name": "pos41" + }, + { + "channel": 0, + "enable": 0, + "id": 41, + "imgName": "", + "name": "pos42" + }, + { + "channel": 0, + "enable": 0, + "id": 42, + "imgName": "", + "name": "pos43" + }, + { + "channel": 0, + "enable": 0, + "id": 43, + "imgName": "", + "name": "pos44" + }, + { + "channel": 0, + "enable": 0, + "id": 44, + "imgName": "", + "name": "pos45" + }, + { + "channel": 0, + "enable": 0, + "id": 45, + "imgName": "", + "name": "pos46" + }, + { + "channel": 0, + "enable": 0, + "id": 46, + "imgName": "", + "name": "pos47" + }, + { + "channel": 0, + "enable": 0, + "id": 47, + "imgName": "", + "name": "pos48" + }, + { + "channel": 0, + "enable": 0, + "id": 48, + "imgName": "", + "name": "pos49" + }, + { + "channel": 0, + "enable": 0, + "id": 49, + "imgName": "", + "name": "pos50" + }, + { + "channel": 0, + "enable": 0, + "id": 50, + "imgName": "", + "name": "pos51" + }, + { + "channel": 0, + "enable": 0, + "id": 51, + "imgName": "", + "name": "pos52" + }, + { + "channel": 0, + "enable": 0, + "id": 52, + "imgName": "", + "name": "pos53" + }, + { + "channel": 0, + "enable": 0, + "id": 53, + "imgName": "", + "name": "pos54" + }, + { + "channel": 0, + "enable": 0, + "id": 54, + "imgName": "", + "name": "pos55" + }, + { + "channel": 0, + "enable": 0, + "id": 55, + "imgName": "", + "name": "pos56" + }, + { + "channel": 0, + "enable": 0, + "id": 56, + "imgName": "", + "name": "pos57" + }, + { + "channel": 0, + "enable": 0, + "id": 57, + "imgName": "", + "name": "pos58" + }, + { + "channel": 0, + "enable": 0, + "id": 58, + "imgName": "", + "name": "pos59" + }, + { + "channel": 0, + "enable": 0, + "id": 59, + "imgName": "", + "name": "pos60" + }, + { + "channel": 0, + "enable": 0, + "id": 60, + "imgName": "", + "name": "pos61" + }, + { + "channel": 0, + "enable": 0, + "id": 61, + "imgName": "", + "name": "pos62" + }, + { + "channel": 0, + "enable": 0, + "id": 62, + "imgName": "", + "name": "pos63" + }, + { + "channel": 0, + "enable": 0, + "id": 63, + "imgName": "", + "name": "pos64" + } + ] + } + } +] diff --git a/examples/response/PtzCheck.json b/examples/response/PtzCheck.json new file mode 100644 index 0000000..8274769 --- /dev/null +++ b/examples/response/PtzCheck.json @@ -0,0 +1 @@ +[{"cmd": "PtzCheck", "code": 0, "value": {"rspCode": 200}}] \ No newline at end of file diff --git a/reolinkapi/mixins/ptz.py b/reolinkapi/mixins/ptz.py index 80841a0..17ed2cb 100644 --- a/reolinkapi/mixins/ptz.py +++ b/reolinkapi/mixins/ptz.py @@ -5,6 +5,39 @@ class PtzAPIMixin: """ API for PTZ functions. """ + def get_ptz_check_state(self) -> Dict: + """ + Get PTZ Check State Information that indicates whether calibration is required (0) running (1) or done (2) + Value is contained in response[0]["value"]["PtzCheckState"]. + See examples/response/GetPtzCheckState.json for example response data. + :return: response json + """ + body = [{"cmd": "GetPtzCheckState", "action": 1, "param": { "channel": 0}}] + return self._execute_command('GetPtzCheckState', body) + + def get_ptz_presets(self) -> Dict: + """ + Get ptz presets + See examples/response/GetPtzPresets.json for example response data. + :return: response json + """ + + body = [{"cmd": "GetPtzPreset", "action": 1, "param": { "channel": 0}}] + return self._execute_command('GetPtzPreset', body) + + def perform_calibration(self) -> Dict: + """ + Do the calibration (like app -> ptz -> three dots -> calibration). Moves camera to all end positions. + If not calibrated, your viewpoint of presets might drift. So before setting new presets, or moving to preset, + check calibration status (get_ptz_check_state -> 2 = calibrated) and perform calibration if not yet calibrated. + As of 2024-01-23 (most recent firmware 3.1.0.1711_23010700 for E1 Zoom) does not do this on startup. + Method blocks while calibrating. + See examples/response/PtzCheck.json for example response data. + :return: response json + """ + data = [{"cmd": "PtzCheck", "action": 0, "param": {"channel": 0}}] + return self._execute_command('PtzCheck', data) + def _send_operation(self, operation: str, speed: float, index: float = None) -> Dict: # Refactored to reduce redundancy param = {"channel": 0, "op": operation, "speed": speed} From 701ff9e3de9cf41fb66a8e6cf454b1e34a5bb58a Mon Sep 17 00:00:00 2001 From: Fabio Testa Date: Tue, 13 Aug 2024 21:31:30 +0200 Subject: [PATCH 087/103] chore: upgrade dependencies and python to 3.12 (#76) * - upgrade deps to python 3.12 - added test for streaming coverage * update setup version print import error --------- Co-authored-by: Fabio Testa --- Pipfile.lock | 169 ------------------------------------ reolinkapi/__init__.py | 2 +- reolinkapi/mixins/stream.py | 10 ++- setup.py | 13 +-- tests/test_camera.py | 6 ++ 5 files changed, 21 insertions(+), 179 deletions(-) delete mode 100644 Pipfile.lock diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 15b8698..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,169 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "idna": { - "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" - ], - "version": "==2.9" - }, - "numpy": { - "hashes": [ - "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6", - "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e", - "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc", - "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc", - "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a", - "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa", - "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3", - "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121", - "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971", - "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26", - "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd", - "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480", - "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec", - "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77", - "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57", - "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07", - "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572", - "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73", - "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca", - "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474", - "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5" - ], - "index": "pypi", - "version": "==1.18.1" - }, - "opencv-python": { - "hashes": [ - "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3", - "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50", - "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb", - "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b", - "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d", - "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a", - "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d", - "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951", - "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c", - "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098", - "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20", - "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f", - "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145", - "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee", - "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b", - "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e", - "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946", - "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db", - "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3", - "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268", - "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179", - "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2", - "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2", - "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9", - "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944", - "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00", - "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417" - ], - "index": "pypi", - "version": "==4.2.0.32" - }, - "pillow": { - "hashes": [ - "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", - "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", - "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", - "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", - "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", - "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", - "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", - "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", - "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", - "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", - "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", - "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", - "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", - "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", - "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", - "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", - "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", - "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", - "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", - "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", - "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", - "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" - ], - "index": "pypi", - "version": "==7.0.0" - }, - "pysocks": { - "hashes": [ - "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", - "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", - "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" - ], - "index": "pypi", - "version": "==1.7.1" - }, - "pyyaml": { - "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" - ], - "index": "pypi", - "version": "==5.3" - }, - "requests": { - "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" - ], - "index": "pypi", - "version": "==2.23.0" - }, - "urllib3": { - "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" - ], - "version": "==1.25.8" - } - }, - "develop": {} -} diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 99bb450..1ded166 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.1.5" +__version__ = "0.2.0" diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 6798a42..3b3a516 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -52,12 +52,16 @@ def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: except Exception as e: print("Could not get Image data\n", e) raise -except ImportError: +except ImportError as err: + print("ImportError", err) + 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: - raise ImportError('''open_video_stream requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"''') + raise ImportError(f'open_video_stream requires streaming extra dependencies\nFor instance "pip install ' + f'reolinkapi[streaming]"') def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional['Image']: - raise ImportError('''open_video_stream requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"''') + raise ImportError( + f'get_snap requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"') diff --git a/setup.py b/setup.py index 8166180..b36aaf8 100644 --- a/setup.py +++ b/setup.py @@ -26,15 +26,16 @@ def find_version(*file_paths): AUTHOR = 'Benehiko' LICENSE = 'GPL-3.0' INSTALL_REQUIRES = [ + 'setuptools', 'PySocks==1.7.1', - 'PyYaml==5.3.1', - 'requests>=2.18.4', + 'PyYaml==6.0.2', + 'requests>=2.32.3', ] EXTRAS_REQUIRE = { 'streaming': [ - 'numpy==1.19.4', - 'opencv-python==4.4.0.46', - 'Pillow==8.0.1', + 'numpy==2.0.1', + 'opencv-python==4.10.0.84', + 'Pillow==10.4.0', ], } @@ -47,7 +48,7 @@ def find_version(*file_paths): setup( name=NAME, - python_requires='>=3.6.0', + python_requires='>=3.12.4', version=find_version('reolinkapi', '__init__.py'), description=DESCRIPTION, long_description=long_description, diff --git a/tests/test_camera.py b/tests/test_camera.py index 67851d0..c5cdf98 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -35,6 +35,12 @@ def test_camera(self): self.assertTrue(self.cam.ip == self.config.get('camera', 'ip')) self.assertTrue(self.cam.token != '') + def test_snapshot(self): + img = self.cam.get_snap() + # write Pillow Image to file + img.save('./tmp/snaps/camera.jpg') + self.assertTrue(os.path.exists('./tmp/snaps/camera.jpg')) + if __name__ == '__main__': unittest.main() From ba497204f181cbfda01c886f50331668eba355b0 Mon Sep 17 00:00:00 2001 From: Sven337 Date: Wed, 14 Aug 2024 18:33:31 +0200 Subject: [PATCH 088/103] feat: example video review application based on motion recordings (#77) * get_motion_files: take channel as argument For "TrackMix" with two lenses, channel 0 is the wide-angle lens while channel 1 is the telephotolens. The "action" parameter of the Search request is documented in Reolink's PDF as being "0", yet this code passes "1". Not modified in this change as it did not seem to prevent correct operation on my trackmix wifi. * download_motions: various fixes - use https (needed on Trackmix wifi) - download videos from the start of today until right now (search cannot span more than a given day) - download videos from the start of yesterday until start of today (to get good coverage, given that spanning more than a day is impossible) - search videos for both channels in main stream - write file names to disk as they are on the camera for later parsing * examples: add Video Review GUI Some documentation here: https://github.com/sven337/ReolinkLinux/wiki#reolink-video-review-gui --- examples/download_motions.py | 22 +- examples/video_review_gui.py | 469 +++++++++++++++++++++++++++++++++++ reolinkapi/mixins/motion.py | 4 +- 3 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 examples/video_review_gui.py diff --git a/examples/download_motions.py b/examples/download_motions.py index 59ec181..809571f 100644 --- a/examples/download_motions.py +++ b/examples/download_motions.py @@ -23,22 +23,32 @@ def read_config(props_path: str) -> dict: # 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') +config = read_config('camera.cfg') ip = config.get('camera', 'ip') un = config.get('camera', 'username') pw = config.get('camera', 'password') # Connect to camera -cam = Camera(ip, un, pw) +cam = Camera(ip, un, pw, https=True) -start = (dt.now() - timedelta(hours=1)) +start = dt.combine(dt.now(), dt.min.time()) end = dt.now() # Collect motion events between these timestamps for substream -processed_motions = cam.get_motion_files(start=start, end=end, streamtype='sub') +processed_motions = cam.get_motion_files(start=start, end=end, streamtype='main', channel=0) +processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) -dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads') +start = dt.now() - timedelta(days=1) +end = dt.combine(start, dt.max.time()) +processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + + +output_files = [] 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')) + print("Getting %s" % (fname)) + output_path = os.path.join('/tmp/', fname.replace('/','_')) + output_files += output_path + if not os.path.isfile(output_path): + resp = cam.get_file(fname, output_path=output_path) diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py new file mode 100644 index 0000000..369ee8b --- /dev/null +++ b/examples/video_review_gui.py @@ -0,0 +1,469 @@ +# Video review GUI +# https://github.com/sven337/ReolinkLinux/wiki#reolink-video-review-gui + +import os +import signal +import sys +import re +import datetime +import queue +import subprocess +from configparser import RawConfigParser +from datetime import datetime as dt, timedelta +from reolinkapi import Camera +from PyQt6.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QFileDialog, QHeaderView, QStyle, QSlider, QStyleOptionSlider, QSplitter +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget +from PyQt6.QtCore import Qt, QUrl, QTimer, QThread, pyqtSignal, QMutex +from PyQt6.QtGui import QColor, QBrush, QFont + +def path_name_from_camera_path(fname): + # Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 + return fname.replace('/', '_') + +# Function to decode hex values into individual flags +def decode_hex_to_flags(hex_value): + flags_mapping = { + 'resolution_index': (21, 7), + 'tv_system': (20, 1), + 'framerate': (13, 7), + 'audio_index': (11, 2), + 'ai_pd': (10, 1), # person + 'ai_fd': (9, 1), # face + 'ai_vd': (8, 1), # vehicle + 'ai_ad': (7, 1), # animal + 'encoder_type_index': (5, 2), + 'is_schedule_record': (4, 1), #scheduled + 'is_motion_record': (3, 1), # motion detected + 'is_rf_record': (2, 1), + 'is_doorbell_press_record': (1, 1), + 'ai_other': (0, 1) + } + hex_value = int(hex_value, 16) # Convert hex string to integer + flag_values = {} + for flag, (bit_position, bit_size) in flags_mapping.items(): + mask = ((1 << bit_size) - 1) << bit_position + flag_values[flag] = (hex_value & mask) >> bit_position + return flag_values + +def parse_filename(file_name): + # Mp4Record_2024-08-12_RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 + # https://github.com/sven337/ReolinkLinux/wiki/Figuring-out-the-file-names#file-name-structure + pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_RecM(\d)\d_DST(\d{8})_(\d{6})_(\d{6})_(\w{4,8})_(\w{4,8})\.mp4' + match = re.match(pattern, file_name) + + if match: + date = match.group(1) # YYYY-MM-DD + channel = int(match.group(2)) # Mx as integer + start_date = match.group(3) # YYYYMMDD + start_time = match.group(4) # HHMMSS + end_time = match.group(5) # HHMMSS + flags_hex = match.group(6) # flags hex + file_size = match.group(7) # second hexadecimal + + # Combine date and start time into a datetime object + start_datetime = datetime.datetime.strptime(f"{start_date} {start_time}", "%Y%m%d %H%M%S") + + triggers = decode_hex_to_flags(flags_hex) + + return {'start_datetime': start_datetime, 'channel': channel, 'end_time': end_time, 'triggers': triggers, 'file_size': file_size} + else: + print("parse error") + return None + +class ClickableSlider(QSlider): + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + val = self.pixelPosToRangeValue(event.pos()) + self.setValue(val) + super().mousePressEvent(event) + + def pixelPosToRangeValue(self, pos): + opt = QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderGroove, self) + sr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self) + + if self.orientation() == Qt.Orientation.Horizontal: + sliderLength = sr.width() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + pos = pos.x() + else: + sliderLength = sr.height() + sliderMin = gr.y() + sliderMax = gr.bottom() - sliderLength + 1 + pos = pos.y() + + return QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos - sliderMin, sliderMax - sliderMin, opt.upsideDown) + +class DownloadThread(QThread): + download_complete = pyqtSignal(str) + download_start = pyqtSignal(str) + + def __init__(self, download_queue, cam): + super().__init__() + self.download_queue = download_queue + self.cam = cam + self.mutex = QMutex() + self.is_running = True + + def run(self): + while self.is_running: + try: + fname, output_path = self.download_queue.get(timeout=1) + output_path = os.path.join(video_storage_dir, output_path) + if os.path.isfile(output_path): + print(f"File already exists: {output_path}") + self.download_complete.emit(output_path) + else: + print(f"Downloading: {fname}") + self.download_start.emit(output_path) + resp = self.cam.get_file(fname, output_path=output_path) + if resp: + print(f"Download complete: {output_path}") + self.download_complete.emit(output_path) + else: + print(f"Download failed: {fname}") + except queue.Empty: + pass + + def stop(self): + self.mutex.lock() + self.is_running = False + self.mutex.unlock() + + +class VideoPlayer(QWidget): + file_exists_signal = pyqtSignal(str) + + def __init__(self, video_files): + super().__init__() + self.setWindowTitle("Reolink Video Review GUI") + self.cam = cam + self.download_queue = queue.Queue() + self.download_thread = DownloadThread(self.download_queue, self.cam) + self.download_thread.download_start.connect(self.on_download_start) + self.download_thread.download_complete.connect(self.on_download_complete) + self.download_thread.start() + + # Create media player + self.media_player = QMediaPlayer() + self.media_player.errorOccurred.connect(self.handle_media_player_error) + + # Create video widget + self.video_widget = QVideoWidget() + self.media_player.setVideoOutput(self.video_widget) + self.media_player.setPlaybackRate(1.5) + + # Create table widget to display video files + self.video_table = QTableWidget() + self.video_table.setColumnCount(9) + self.video_table.setHorizontalHeaderLabels(["Video Path", "Start Datetime", "End Time", "Channel", "Person", "Vehicle", "Pet", "Motion", "Timer" ]) + self.video_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) + self.video_table.setSortingEnabled(True) + self.video_table.cellClicked.connect(self.play_video) + + # Set smaller default column widths + self.video_table.setColumnWidth(0, 120) # Video Path + self.video_table.setColumnWidth(1, 130) # Start Datetime + self.video_table.setColumnWidth(2, 80) # End Time + self.video_table.setColumnWidth(3, 35) # Channel + self.video_table.setColumnWidth(4, 35) # Person + self.video_table.setColumnWidth(5, 35) # Vehicle + self.video_table.setColumnWidth(6, 35) # Pet + self.video_table.setColumnWidth(7, 35) # Motion + self.video_table.setColumnWidth(8, 30) # Timer + + # Create open button to select video files + self.open_button = QPushButton("Open Videos") + self.open_button.clicked.connect(self.open_videos) + + self.play_button = QPushButton() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self.play_button.clicked.connect(self.play_pause) + + self.mpv_button = QPushButton("MPV") + self.mpv_button.clicked.connect(self.open_in_mpv) + + # Create seek slider + self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal) + self.seek_slider.setRange(0, 0) + self.seek_slider.sliderPressed.connect(self.seek_slider_pressed) + self.seek_slider.sliderReleased.connect(self.seek_slider_released) + self.seek_slider.sliderMoved.connect(self.seek_slider_moved) + self.media_player.positionChanged.connect(self.update_position) + self.media_player.durationChanged.connect(self.update_duration) + + # Create playback speed slider + self.speed_slider = QSlider(Qt.Orientation.Horizontal) + self.speed_slider.setRange(50, 300) + self.speed_slider.setValue(200) + self.speed_slider.valueChanged.connect(self.set_speed) + speed_label = QLabel("Speed: 2.0x") + self.speed_slider.valueChanged.connect(lambda v: speed_label.setText(f"Speed: {v/100:.1f}x")) + + main_layout = QHBoxLayout() + # Create a splitter + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left side (table and open button) + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.addWidget(self.video_table) + left_layout.addWidget(self.open_button) + splitter.addWidget(left_widget) + + # Right side (video player and controls) + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.addWidget(self.video_widget, 1) + + controls_widget = QWidget() + controls_layout = QVBoxLayout(controls_widget) + + control_layout = QHBoxLayout() + control_layout.addWidget(self.play_button) + control_layout.addWidget(self.seek_slider) + control_layout.addWidget(self.mpv_button) + controls_layout.addLayout(control_layout) + + speed_layout = QHBoxLayout() + speed_layout.addWidget(QLabel("Speed:")) + speed_layout.addWidget(self.speed_slider) + speed_layout.addWidget(speed_label) + controls_layout.addLayout(speed_layout) + right_layout.addWidget(controls_widget) + splitter.addWidget(right_widget) + + # Set initial sizes + splitter.setSizes([300, 700]) + + main_layout.addWidget(splitter) + self.setLayout(main_layout) + self.file_exists_signal.connect(self.on_download_complete) + + self.add_initial_videos(video_files) + + def add_initial_videos(self, video_files): + for video_path in video_files: + self.add_video(video_path) + self.video_table.sortItems(1, Qt.SortOrder.DescendingOrder) + + def open_videos(self): + file_dialog = QFileDialog(self) + file_dialog.setNameFilters(["Videos (*.mp4 *.avi *.mov)"]) + file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles) + if file_dialog.exec(): + self.video_table.setSortingEnabled(False) + for file in file_dialog.selectedFiles(): + self.add_video(os.path.basename(file)) + self.video_table.setSortingEnabled(True) + self.video_table.sortItems(1, Qt.SortOrder.DescendingOrder) + + def add_video(self, video_path): + # We are passed the camera file name, e.g. Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 + file_path = path_name_from_camera_path(video_path) + base_file_name = file_path + parsed_data = parse_filename(file_path) + if parsed_data: + row_position = self.video_table.rowCount() + self.video_table.insertRow(row_position) + start_datetime_str = parsed_data['start_datetime'].strftime("%Y-%m-%d %H:%M:%S") + start_datetime_item = QTableWidgetItem(start_datetime_str) + start_datetime_item.setData(Qt.ItemDataRole.UserRole, parsed_data['start_datetime']) + + # Create the item for the first column with the base file name + file_name_item = QTableWidgetItem(base_file_name) + + # Set the full path as tooltip + file_name_item.setToolTip(base_file_name) + + # Set the style for queued status + grey_color = QColor(200, 200, 200) # Light grey + file_name_item.setForeground(QBrush(grey_color)) + font = QFont() + font.setItalic(True) + file_name_item.setFont(font) + + self.video_table.setItem(row_position, 0, file_name_item) + self.video_table.setItem(row_position, 1, start_datetime_item) + self.video_table.setItem(row_position, 2, QTableWidgetItem(parsed_data['end_time'])) + self.video_table.setItem(row_position, 3, QTableWidgetItem(f"{parsed_data['channel']}")) + + # Set individual trigger flags + self.video_table.setItem(row_position, 4, QTableWidgetItem("✓" if parsed_data['triggers']['ai_pd'] else "")) + self.video_table.setItem(row_position, 5, QTableWidgetItem("✓" if parsed_data['triggers']['ai_vd'] else "")) + self.video_table.setItem(row_position, 6, QTableWidgetItem("✓" if parsed_data['triggers']['ai_ad'] else "")) + self.video_table.setItem(row_position, 7, QTableWidgetItem("✓" if parsed_data['triggers']['is_motion_record'] else "")) + self.video_table.setItem(row_position, 8, QTableWidgetItem("✓" if parsed_data['triggers']['is_schedule_record'] else "")) + + if parsed_data['triggers']['ai_other']: + print(f"File {file_path} has ai_other flag!") + + # Make the fields non-editable + for column in range(self.video_table.columnCount()): + item = self.video_table.item(row_position, column) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + + output_path = os.path.join(video_storage_dir, base_file_name) + if os.path.isfile(output_path): + self.file_exists_signal.emit(output_path) + else: + # Add to download queue + self.download_queue.put((video_path, base_file_name)) + else: + print(f"Could not parse file {video_path}") + + def on_download_complete(self, video_path): + for row in range(self.video_table.rowCount()): + if self.video_table.item(row, 0).text() == os.path.basename(video_path): + file_name_item = self.video_table.item(row, 0) + file_name_item.setForeground(QBrush(QColor(0, 0, 0))) # Black color for normal text + font = QFont() + font.setItalic(False) + font.setBold(False) + file_name_item.setFont(font) + break + + def on_download_start(self, video_path): + for row in range(self.video_table.rowCount()): + if self.video_table.item(row, 0).text() == os.path.basename(video_path): + file_name_item = self.video_table.item(row, 0) + grey_color = QColor(200, 200, 200) # Light grey + file_name_item.setForeground(QBrush(grey_color)) + font = QFont() + font.setBold(True) + file_name_item.setFont(font) + break + + def play_video(self, row, column): + file_name_item = self.video_table.item(row, 0) + video_path = os.path.join(video_storage_dir, file_name_item.text()) + + if file_name_item.font().italic() or file_name_item.foreground().color().lightness() >= 200: + print(f"Video {video_path} is not yet downloaded. Please wait.") + return + + print(f"Playing video: {video_path}") + url = QUrl.fromLocalFile(video_path) + self.media_player.setSource(url) + self.video_widget.show() + def start_playback(): + # Seek to 5 seconds (pre-record) + self.media_player.setPosition(5000) + self.media_player.play() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause)) + print(f"Media player state: {self.media_player.playbackState()}") + + # Timer needed to be able to play at seek offset in the video, otherwise setPosition seems ignored + QTimer.singleShot(20, start_playback) + + def play_pause(self): + if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.media_player.pause() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + else: + self.media_player.play() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause)) + + def handle_media_player_error(self, error): + print(f"Media player error: {error}") + + def seek_slider_pressed(self): + self.media_player.setPosition(self.seek_slider.value()) + self.media_player.play() + + def seek_slider_released(self): + self.media_player.setPosition(self.seek_slider.value()) + self.media_player.play() + + def seek_slider_moved(self, position): + # Update video frame while dragging + self.media_player.setPosition(position) + + def update_position(self, position): + self.seek_slider.setValue(position) + + def update_duration(self, duration): + self.seek_slider.setRange(0, duration) + + def set_speed(self, speed): + self.media_player.setPlaybackRate(speed / 100) + + def open_in_mpv(self): + current_video = self.media_player.source().toString() + if current_video: + # Remove the 'file://' prefix if present + video_path = current_video.replace('file://', '') + try: + subprocess.Popen(['mpv', video_path]) + except FileNotFoundError: + print("Error: MPV player not found. Make sure it's installed and in your system PATH.") + else: + print("No video is currently selected.") + + def closeEvent(self, event): + self.download_thread.stop() + self.download_thread.wait() + super().closeEvent(event) + +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 + + +def signal_handler(sig, frame): + print("Exiting the application...") + sys.exit(0) + + + +if __name__ == '__main__': + signal.signal(signal.SIGINT, signal_handler) +# 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('camera.cfg') + + ip = config.get('camera', 'ip') + un = config.get('camera', 'username') + pw = config.get('camera', 'password') + video_storage_dir = config.get('camera', 'video_storage_dir') + +# Connect to camera + cam = Camera(ip, un, pw, https=True) + + start = dt.combine(dt.now(), dt.min.time()) + end = dt.now() + processed_motions = cam.get_motion_files(start=start, end=end, streamtype='main', channel=0) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + + start = dt.now() - timedelta(days=1) + end = dt.combine(start, dt.max.time()) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=0) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + + + video_files = [] + for i, motion in enumerate(processed_motions): + fname = motion['filename'] + print("Processing %s" % (fname)) + video_files.append(fname) + + video_files.extend(sys.argv[1:]) + app = QApplication(sys.argv) + player = VideoPlayer(video_files) + player.resize(1900, 1000) + player.show() + sys.exit(app.exec()) diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py index 73d3437..6aada63 100644 --- a/reolinkapi/mixins/motion.py +++ b/reolinkapi/mixins/motion.py @@ -10,7 +10,7 @@ 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: + streamtype: str = 'sub', channel = 0) -> PROCESSED_MOTION_LIST_TYPE: """ Get the timestamps and filenames of motion detection events for the time range provided. @@ -22,7 +22,7 @@ def get_motion_files(self, start: dt, end: dt = dt.now(), """ search_params = { 'Search': { - 'channel': 0, + 'channel': channel, 'streamType': streamtype, 'onlyStatus': 0, 'StartTime': { From ab7f453a6c617ff0f58e1e39671ea679ed4821fd Mon Sep 17 00:00:00 2001 From: Sven337 Date: Thu, 15 Aug 2024 19:01:04 +0200 Subject: [PATCH 089/103] Implement Reolink PTZ Streamer GUI app (#78) * stream gui start * initial video stream gui with PTZ -- pan works, zoom broken * stream gui: pan and zoom work, needs refinement * stream gui: some improvements .latency still terrible and key repeat is a problem * stream gui: fix autorepeat messing up commands Includes minor cleanups for publication * video_review_gui: disable SSL warnings The camera uses a self-signed certificate so prevent the annoying spamming. --- examples/stream_gui.py | 175 +++++++++++++++++++++++++++++++++++ examples/video_review_gui.py | 3 + 2 files changed, 178 insertions(+) create mode 100644 examples/stream_gui.py diff --git a/examples/stream_gui.py b/examples/stream_gui.py new file mode 100644 index 0000000..45607b3 --- /dev/null +++ b/examples/stream_gui.py @@ -0,0 +1,175 @@ +import sys +import os +from configparser import RawConfigParser +from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QSlider +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget +from PyQt6.QtCore import Qt, QUrl, QTimer +from PyQt6.QtGui import QWheelEvent +from reolinkapi import Camera +from threading import Lock + +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def read_config(props_path: str) -> dict: + config = RawConfigParser() + assert os.path.exists(props_path), f"Path does not exist: {props_path}" + config.read(props_path) + return config + +class ZoomSlider(QSlider): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def keyPressEvent(self, event): + if event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): + event.ignore() + else: + super().keyPressEvent(event) + +class CameraPlayer(QWidget): + def __init__(self, rtsp_url_wide, rtsp_url_telephoto, camera: Camera): + super().__init__() + self.setWindowTitle("Reolink PTZ Streamer") + self.setGeometry(10, 10, 1900, 600) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + self.camera = camera + self.zoom_timer = QTimer(self) + self.zoom_timer.timeout.connect(self.stop_zoom) + + # Create media players + self.media_player_wide = QMediaPlayer() + self.media_player_telephoto = QMediaPlayer() + + # Create video widgets + self.video_widget_wide = QVideoWidget() + self.video_widget_telephoto = QVideoWidget() + self.media_player_wide.setVideoOutput(self.video_widget_wide) + self.media_player_telephoto.setVideoOutput(self.video_widget_telephoto) + self.video_widget_wide.wheelEvent = self.handle_wheel_event + self.video_widget_telephoto.wheelEvent = self.handle_wheel_event + + # Create layout + layout = QHBoxLayout() + layout.addWidget(self.video_widget_wide, 2) + layout.addWidget(self.video_widget_telephoto, 2) + self.setLayout(layout) + + # Start playing the streams + self.media_player_wide.setSource(QUrl(rtsp_url_wide)) + self.media_player_telephoto.setSource(QUrl(rtsp_url_telephoto)) + self.media_player_wide.play() + self.media_player_telephoto.play() + + + def keyPressEvent(self, event): + if event.isAutoRepeat(): + return + if event.key() == Qt.Key.Key_Escape: + self.close() + elif event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): + self.start_move(event.key()) + + def keyReleaseEvent(self, event): + if event.isAutoRepeat(): + return + if event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): + self.stop_move() + + def start_move(self, key): + direction = { + Qt.Key.Key_Left: "left", + Qt.Key.Key_Right: "right", + Qt.Key.Key_Up: "up", + Qt.Key.Key_Down: "down" + }.get(key) + + if direction: + self.move_camera(direction) + + def stop_move(self): + response = self.camera.stop_ptz() + print("Stop PTZ") + if response[0].get('code') != 0: + self.show_error_message("Failed to stop camera movement", str(response[0])) + + def move_camera(self, direction): + speed = 25 + if direction == "left": + response = self.camera.move_left(speed) + elif direction == "right": + response = self.camera.move_right(speed) + elif direction == "up": + response = self.camera.move_up(speed) + elif direction == "down": + response = self.camera.move_down(speed) + else: + print(f"Invalid direction: {direction}") + return + + if response[0].get('code') == 0: + print(f"Moving camera {direction}") + else: + self.show_error_message(f"Failed to move camera {direction}", str(response[0])) + + def handle_wheel_event(self, event: QWheelEvent): + delta = event.angleDelta().y() + if delta > 0: + self.zoom_in() + elif delta < 0: + self.zoom_out() + + def zoom_in(self): + self.start_zoom('in') + + def zoom_out(self): + self.start_zoom('out') + + def start_zoom(self, direction: str): + self.zoom_timer.stop() # Stop any ongoing zoom timer + speed = 60 # You can adjust this value as needed + if direction == 'in': + response = self.camera.start_zooming_in(speed) + else: + response = self.camera.start_zooming_out(speed) + + if response[0].get('code') == 0: + print(f"Zooming {direction}") + self.zoom_timer.start(200) # Stop zooming after 200ms + else: + self.show_error_message(f"Failed to start zooming {direction}", str(response[0])) + + def stop_zoom(self): + response = self.camera.stop_zooming() + if response[0].get('code') != 0: + self.show_error_message("Failed to stop zooming", str(response[0])) + + def show_error_message(self, title, message): + print(f"Error: {title} {message}") + + def handle_error(self, error): + print(f"Media player error: {error}") + + +if __name__ == '__main__': + # Read in your ip, username, & password from the configuration file + config = read_config('camera.cfg') + ip = config.get('camera', 'ip') + un = config.get('camera', 'username') + pw = config.get('camera', 'password') + + # Connect to camera + cam = Camera(ip, un, pw, https=True) + + rtsp_url_wide = f"rtsp://{un}:{pw}@{ip}/Preview_01_sub" + rtsp_url_telephoto = f"rtsp://{un}:{pw}@{ip}/Preview_02_sub" + + # Connect to camera + cam = Camera(ip, un, pw, https=True) + + app = QApplication(sys.argv) + player = CameraPlayer(rtsp_url_wide, rtsp_url_telephoto, cam) + player.show() + sys.exit(app.exec()) diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py index 369ee8b..93a1c36 100644 --- a/examples/video_review_gui.py +++ b/examples/video_review_gui.py @@ -17,6 +17,9 @@ from PyQt6.QtCore import Qt, QUrl, QTimer, QThread, pyqtSignal, QMutex from PyQt6.QtGui import QColor, QBrush, QFont +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + def path_name_from_camera_path(fname): # Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 return fname.replace('/', '_') From 05d29ebca818c8d1ce1b35f3a3a7529ed82cf62a Mon Sep 17 00:00:00 2001 From: Sven337 Date: Thu, 15 Aug 2024 19:01:51 +0200 Subject: [PATCH 090/103] fix: video review example (#79) * video review gui: fix filename pattern to handle sub stream also * video review gui: disable SSL warnings The camera uses a self-signed certificate so prevent the annoying spamming. --- examples/video_review_gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py index 93a1c36..bff9fd1 100644 --- a/examples/video_review_gui.py +++ b/examples/video_review_gui.py @@ -52,7 +52,7 @@ def decode_hex_to_flags(hex_value): def parse_filename(file_name): # Mp4Record_2024-08-12_RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 # https://github.com/sven337/ReolinkLinux/wiki/Figuring-out-the-file-names#file-name-structure - pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_RecM(\d)\d_DST(\d{8})_(\d{6})_(\d{6})_(\w{4,8})_(\w{4,8})\.mp4' + pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_Rec[MS](\d)\d_DST(\d{8})_(\d{6})_(\d{6})_(\w{4,8})_(\w{4,8})\.mp4' match = re.match(pattern, file_name) if match: From 7b9f02c899e1fb6c1dbedf3e8cbb98e1a7bed3de Mon Sep 17 00:00:00 2001 From: Sven337 Date: Tue, 8 Oct 2024 22:11:47 +0200 Subject: [PATCH 091/103] feat: updates to video review GUI (#80) * video review gui: various improvements - clicking on a yet-to-be-downloaded video moves it to the top of the queue so it is downloaded next - make sure the download thread sleeps when it's done (d'oh!) - add a "status" column, later changes will download by default in low res and offer the option to use higher res * video review gui: add status column Add status column with icons to indicate download status * assorted updates * upd * upd * video_review_gui: more updates - re-download file if it exists but filesize doesn't match expected value - use a QTreeWidget instead of a QTableWidget in order to present channel 1 as a child of channel 0 (you never want to watch channel 1 without having established that channel 0 had something interesting) - add CLI --sub to search the sub stream (faster downloads = faster review) - the new logic also presents sub & main in the same tree root (conceptually, one start datetime = tree root under which all video related to that moment are listed) - add icons for statuses - exit after 1second timeout instead of being blocked until end of download * video_review: fix icons with sub stream * bump to width 2400 * support new version 9 of filenames * add GetHighRes button to download from main stream when running in sub mode --- examples/stream_gui.py | 2 +- examples/video_review_gui.py | 426 ++++++++++++++++++++++++----------- 2 files changed, 296 insertions(+), 132 deletions(-) diff --git a/examples/stream_gui.py b/examples/stream_gui.py index 45607b3..c174bf9 100644 --- a/examples/stream_gui.py +++ b/examples/stream_gui.py @@ -32,7 +32,7 @@ class CameraPlayer(QWidget): def __init__(self, rtsp_url_wide, rtsp_url_telephoto, camera: Camera): super().__init__() self.setWindowTitle("Reolink PTZ Streamer") - self.setGeometry(10, 10, 1900, 600) + self.setGeometry(10, 10, 2400, 900) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.camera = camera diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py index bff9fd1..011d8eb 100644 --- a/examples/video_review_gui.py +++ b/examples/video_review_gui.py @@ -6,16 +6,17 @@ import sys import re import datetime -import queue import subprocess +import argparse from configparser import RawConfigParser from datetime import datetime as dt, timedelta from reolinkapi import Camera -from PyQt6.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QFileDialog, QHeaderView, QStyle, QSlider, QStyleOptionSlider, QSplitter +from PyQt6.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QFileDialog, QHeaderView, QStyle, QSlider, QStyleOptionSlider, QSplitter, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimediaWidgets import QVideoWidget -from PyQt6.QtCore import Qt, QUrl, QTimer, QThread, pyqtSignal, QMutex -from PyQt6.QtGui import QColor, QBrush, QFont +from PyQt6.QtCore import Qt, QUrl, QTimer, QThread, pyqtSignal, QMutex, QWaitCondition +from PyQt6.QtGui import QColor, QBrush, QFont, QIcon +from collections import deque import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -51,28 +52,61 @@ def decode_hex_to_flags(hex_value): def parse_filename(file_name): # Mp4Record_2024-08-12_RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 + # Mp4Record_2024-09-13-RecS09_DST20240907_084519_084612_0_55289080000000_307BC0.mp4 # https://github.com/sven337/ReolinkLinux/wiki/Figuring-out-the-file-names#file-name-structure - pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_Rec[MS](\d)\d_DST(\d{8})_(\d{6})_(\d{6})_(\w{4,8})_(\w{4,8})\.mp4' + pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_Rec[MS](\d)(\d)_(DST)?(\d{8})_(\d{6})_(\d{6})' + v3_suffix = r'.*_(\w{4,8})_(\w{4,8})\.mp4' + v9_suffix = r'.*_(\d)_(\w{7})(\w{7})_(\w{4,8})\.mp4' match = re.match(pattern, file_name) + out = {} + version = 0 + if match: date = match.group(1) # YYYY-MM-DD - channel = int(match.group(2)) # Mx as integer - start_date = match.group(3) # YYYYMMDD - start_time = match.group(4) # HHMMSS - end_time = match.group(5) # HHMMSS - flags_hex = match.group(6) # flags hex - file_size = match.group(7) # second hexadecimal - + channel = int(match.group(2)) + version = int(match.group(3)) # version + start_date = match.group(5) # YYYYMMDD + start_time = match.group(6) # HHMMSS + end_time = match.group(7) # HHMMSS + # Combine date and start time into a datetime object start_datetime = datetime.datetime.strptime(f"{start_date} {start_time}", "%Y%m%d %H%M%S") - triggers = decode_hex_to_flags(flags_hex) - - return {'start_datetime': start_datetime, 'channel': channel, 'end_time': end_time, 'triggers': triggers, 'file_size': file_size} + out = {'start_datetime': start_datetime, 'channel': channel, 'end_time': end_time } else: print("parse error") return None + + if version == 9: + match = re.match(v9_suffix, file_name) + if not match: + print(f"v9 parse error for {file_name}") + return None + + animal_type = match.group(1) + flags_hex1 = match.group(2) + flags_hex2 = match.group(3) + file_size = int(match.group(4), 16) + + triggers = decode_hex_to_flags(flags_hex1) + + out.update({'animal_type' : animal_type, 'file_size' : file_size, 'triggers' : triggers }) + + elif version == 2 or version == 3: + match = re.match(v3_suffix, file_name) + if not match: + print(f"v3 parse error for {file_name}") + return None + + flags_hex = match.group(1) + file_size = int(match.group(2), 16) + + triggers = decode_hex_to_flags(flags_hex) + + out.update({'file_size' : file_size, 'triggers' : triggers }) + + return out class ClickableSlider(QSlider): def mousePressEvent(self, event): @@ -101,7 +135,7 @@ def pixelPosToRangeValue(self, pos): return QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos - sliderMin, sliderMax - sliderMin, opt.upsideDown) class DownloadThread(QThread): - download_complete = pyqtSignal(str) + download_complete = pyqtSignal(str, bool) download_start = pyqtSignal(str) def __init__(self, download_queue, cam): @@ -109,42 +143,58 @@ def __init__(self, download_queue, cam): self.download_queue = download_queue self.cam = cam self.mutex = QMutex() + self.wait_condition = QWaitCondition() self.is_running = True def run(self): while self.is_running: + self.mutex.lock() + if len(self.download_queue) == 0: + self.wait_condition.wait(self.mutex) + self.mutex.unlock() + + if not self.is_running: + break + try: - fname, output_path = self.download_queue.get(timeout=1) + fname, output_path = self.download_queue.popleft() output_path = os.path.join(video_storage_dir, output_path) - if os.path.isfile(output_path): - print(f"File already exists: {output_path}") - self.download_complete.emit(output_path) + print(f"Downloading: {fname}") + self.download_start.emit(output_path) + resp = self.cam.get_file(fname, output_path=output_path) + if resp: + print(f"Download complete: {output_path}") + self.download_complete.emit(output_path, True) else: - print(f"Downloading: {fname}") - self.download_start.emit(output_path) - resp = self.cam.get_file(fname, output_path=output_path) - if resp: - print(f"Download complete: {output_path}") - self.download_complete.emit(output_path) - else: - print(f"Download failed: {fname}") - except queue.Empty: + print(f"Download failed: {fname}") + self.download_complete.emit(output_path, False) + except IndexError: pass def stop(self): self.mutex.lock() self.is_running = False self.mutex.unlock() + self.wait_condition.wakeAll() + + def add_to_queue(self, fname, output_path, left=False): + self.mutex.lock() + if left: + self.download_queue.appendleft((fname, output_path)) + else: + self.download_queue.append((fname, output_path)) + self.wait_condition.wakeOne() + self.mutex.unlock() class VideoPlayer(QWidget): - file_exists_signal = pyqtSignal(str) + file_exists_signal = pyqtSignal(str, bool) def __init__(self, video_files): super().__init__() self.setWindowTitle("Reolink Video Review GUI") self.cam = cam - self.download_queue = queue.Queue() + self.download_queue = deque() self.download_thread = DownloadThread(self.download_queue, self.cam) self.download_thread.download_start.connect(self.on_download_start) self.download_thread.download_complete.connect(self.on_download_complete) @@ -160,23 +210,27 @@ def __init__(self, video_files): self.media_player.setPlaybackRate(1.5) # Create table widget to display video files - self.video_table = QTableWidget() - self.video_table.setColumnCount(9) - self.video_table.setHorizontalHeaderLabels(["Video Path", "Start Datetime", "End Time", "Channel", "Person", "Vehicle", "Pet", "Motion", "Timer" ]) - self.video_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) - self.video_table.setSortingEnabled(True) - self.video_table.cellClicked.connect(self.play_video) + self.video_tree = QTreeWidget() + self.video_tree.setColumnCount(10) + self.video_tree.setHeaderLabels(["Status", "Video Path", "Start Datetime", "End Time", "Channel", "Person", "Vehicle", "Pet", "Motion", "Timer"]) + self.video_tree.setSortingEnabled(True) + self.video_tree.itemClicked.connect(self.play_video) # Set smaller default column widths - self.video_table.setColumnWidth(0, 120) # Video Path - self.video_table.setColumnWidth(1, 130) # Start Datetime - self.video_table.setColumnWidth(2, 80) # End Time - self.video_table.setColumnWidth(3, 35) # Channel - self.video_table.setColumnWidth(4, 35) # Person - self.video_table.setColumnWidth(5, 35) # Vehicle - self.video_table.setColumnWidth(6, 35) # Pet - self.video_table.setColumnWidth(7, 35) # Motion - self.video_table.setColumnWidth(8, 30) # Timer + self.video_tree.setColumnWidth(0, 35) # Status + self.video_tree.setColumnWidth(1, 120) # Video Path + self.video_tree.setColumnWidth(2, 130) # Start Datetime + self.video_tree.setColumnWidth(3, 70) # End Time + self.video_tree.setColumnWidth(4, 35) # Channel + self.video_tree.setColumnWidth(5, 35) # Person + self.video_tree.setColumnWidth(6, 35) # Vehicle + self.video_tree.setColumnWidth(7, 35) # Pet + self.video_tree.setColumnWidth(8, 35) # Motion + self.video_tree.setColumnWidth(9, 30) # Timer + + self.video_tree.setIndentation(10) + + QIcon.setThemeName("Adwaita") # Create open button to select video files self.open_button = QPushButton("Open Videos") @@ -188,7 +242,11 @@ def __init__(self, video_files): self.mpv_button = QPushButton("MPV") self.mpv_button.clicked.connect(self.open_in_mpv) - + + self.get_highres_button = QPushButton("GetHighRes") + self.get_highres_button.clicked.connect(self.get_highres_stream_for_file) + self.get_highres_button.setEnabled(False) # Disable by default + # Create seek slider self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal) self.seek_slider.setRange(0, 0) @@ -213,7 +271,7 @@ def __init__(self, video_files): # Left side (table and open button) left_widget = QWidget() left_layout = QVBoxLayout(left_widget) - left_layout.addWidget(self.video_table) + left_layout.addWidget(self.video_tree) left_layout.addWidget(self.open_button) splitter.addWidget(left_widget) @@ -229,6 +287,7 @@ def __init__(self, video_files): control_layout.addWidget(self.play_button) control_layout.addWidget(self.seek_slider) control_layout.addWidget(self.mpv_button) + control_layout.addWidget(self.get_highres_button) controls_layout.addLayout(control_layout) speed_layout = QHBoxLayout() @@ -251,103 +310,173 @@ def __init__(self, video_files): def add_initial_videos(self, video_files): for video_path in video_files: self.add_video(video_path) - self.video_table.sortItems(1, Qt.SortOrder.DescendingOrder) + self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder) +# self.video_tree.expandAll() def open_videos(self): file_dialog = QFileDialog(self) file_dialog.setNameFilters(["Videos (*.mp4 *.avi *.mov)"]) file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles) if file_dialog.exec(): - self.video_table.setSortingEnabled(False) + self.video_tree.setSortingEnabled(False) for file in file_dialog.selectedFiles(): self.add_video(os.path.basename(file)) - self.video_table.setSortingEnabled(True) - self.video_table.sortItems(1, Qt.SortOrder.DescendingOrder) + self.video_tree.setSortingEnabled(True) + self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder) +# self.video_tree.expandAll() def add_video(self, video_path): # We are passed the camera file name, e.g. Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 file_path = path_name_from_camera_path(video_path) base_file_name = file_path parsed_data = parse_filename(file_path) - if parsed_data: - row_position = self.video_table.rowCount() - self.video_table.insertRow(row_position) - start_datetime_str = parsed_data['start_datetime'].strftime("%Y-%m-%d %H:%M:%S") - start_datetime_item = QTableWidgetItem(start_datetime_str) - start_datetime_item.setData(Qt.ItemDataRole.UserRole, parsed_data['start_datetime']) - - # Create the item for the first column with the base file name - file_name_item = QTableWidgetItem(base_file_name) + if not parsed_data: + print(f"Could not parse file {video_path}") + return + + start_datetime = parsed_data['start_datetime'] + channel = parsed_data['channel'] + + end_time = datetime.datetime.strptime(parsed_data['end_time'], "%H%M%S") + end_time_str = end_time.strftime("%H:%M:%S") + + video_item = QTreeWidgetItem() + video_item.setText(0, "") # Status + video_item.setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) + video_item.setText(1, base_file_name) + video_item.setText(2, start_datetime.strftime("%Y-%m-%d %H:%M:%S")) + video_item.setData(2, Qt.ItemDataRole.UserRole, parsed_data['start_datetime']) + video_item.setText(3, end_time_str) + video_item.setText(4, str(channel)) + video_item.setText(5, "✓" if parsed_data['triggers']['ai_pd'] else "") + video_item.setText(6, "✓" if parsed_data['triggers']['ai_vd'] else "") + video_item.setText(7, "✓" if parsed_data['triggers']['ai_ad'] else "") + video_item.setText(8, "✓" if parsed_data['triggers']['is_motion_record'] else "") + video_item.setText(9, "✓" if parsed_data['triggers']['is_schedule_record'] else "") + + if parsed_data['triggers']['ai_other']: + print(f"File {file_path} has ai_other flag!") + + video_item.setToolTip(1, base_file_name) + # Set the style for queued status + grey_color = QColor(200, 200, 200) # Light grey + video_item.setForeground(1, QBrush(grey_color)) + font = QFont() + font.setItalic(True) + video_item.setFont(1, font) + + # Make the fields non-editable + iterator = QTreeWidgetItemIterator(self.video_tree) + while iterator.value(): + item = iterator.value() + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + iterator += 1 + + # Find a potentially pre-existing channel0 item for this datetime, if so, add as a child + # This lets channel1 appear as a child, but also main & sub videos appear in the same group + channel_0_item = self.find_channel_0_item(start_datetime) + if channel_0_item: + channel_0_item.addChild(video_item) + else: + self.video_tree.addTopLevelItem(video_item) - # Set the full path as tooltip - file_name_item.setToolTip(base_file_name) + output_path = os.path.join(video_storage_dir, base_file_name) + expected_size = parsed_data['file_size'] - # Set the style for queued status - grey_color = QColor(200, 200, 200) # Light grey - file_name_item.setForeground(QBrush(grey_color)) - font = QFont() - font.setItalic(True) - file_name_item.setFont(font) - - self.video_table.setItem(row_position, 0, file_name_item) - self.video_table.setItem(row_position, 1, start_datetime_item) - self.video_table.setItem(row_position, 2, QTableWidgetItem(parsed_data['end_time'])) - self.video_table.setItem(row_position, 3, QTableWidgetItem(f"{parsed_data['channel']}")) - - # Set individual trigger flags - self.video_table.setItem(row_position, 4, QTableWidgetItem("✓" if parsed_data['triggers']['ai_pd'] else "")) - self.video_table.setItem(row_position, 5, QTableWidgetItem("✓" if parsed_data['triggers']['ai_vd'] else "")) - self.video_table.setItem(row_position, 6, QTableWidgetItem("✓" if parsed_data['triggers']['ai_ad'] else "")) - self.video_table.setItem(row_position, 7, QTableWidgetItem("✓" if parsed_data['triggers']['is_motion_record'] else "")) - self.video_table.setItem(row_position, 8, QTableWidgetItem("✓" if parsed_data['triggers']['is_schedule_record'] else "")) - - if parsed_data['triggers']['ai_other']: - print(f"File {file_path} has ai_other flag!") - - # Make the fields non-editable - for column in range(self.video_table.columnCount()): - item = self.video_table.item(row_position, column) - item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - output_path = os.path.join(video_storage_dir, base_file_name) - if os.path.isfile(output_path): - self.file_exists_signal.emit(output_path) + need_download = True + if os.path.isfile(output_path): + actual_size = os.path.getsize(output_path) + if actual_size == expected_size: + need_download = False + self.file_exists_signal.emit(output_path, True) else: - # Add to download queue - self.download_queue.put((video_path, base_file_name)) + print(f"File size mismatch for {output_path}. Expected: {expected_size}, Actual: {actual_size}. Downloading again") + + if need_download: + video_item.setIcon(1, self.style().standardIcon(QStyle.StandardPixmap.SP_CommandLink)) + self.download_thread.add_to_queue(video_path, base_file_name) + + def find_channel_0_item(self, datetime_obj): + # Truncate seconds to nearest 10 + truncated_seconds = datetime_obj.second - (datetime_obj.second % 10) + truncated_datetime = datetime_obj.replace(second=truncated_seconds) + + for i in range(self.video_tree.topLevelItemCount()): + item = self.video_tree.topLevelItem(i) + item_datetime = item.data(2, Qt.ItemDataRole.UserRole) + item_truncated = item_datetime.replace(second=item_datetime.second - (item_datetime.second % 10)) + if item_truncated == truncated_datetime: + return item + return None + + def find_item_by_path(self, path): + iterator = QTreeWidgetItemIterator(self.video_tree) + while iterator.value(): + item = iterator.value() + text = item.text(1) + if text == path: + return item + iterator += 1 + print(f"Could not find item by path {path}") + return None + + def on_download_complete(self, video_path, success): + item = self.find_item_by_path(os.path.basename(video_path)) + if not item: + print(f"on_download_complete {video_path} did not find item?!") + item.setForeground(1, QBrush(QColor(0, 0, 0))) # Black color for normal text + font = item.font(1) + font.setItalic(False) + font.setBold(False) + item.setFont(1, font) + if success: + if "RecS" in item.text(1): + # One day (hopefully) offer the option to download the + # high-res version This is not trivial because we have to + # re-do a camera search for the relevant time period and + # match based on start and end dates (+/- one second in my + # experience) + # For now simply display that this is low-res. + item.setText(0, "sub") + item.setIcon(1, QIcon()) else: - print(f"Could not parse file {video_path}") - - def on_download_complete(self, video_path): - for row in range(self.video_table.rowCount()): - if self.video_table.item(row, 0).text() == os.path.basename(video_path): - file_name_item = self.video_table.item(row, 0) - file_name_item.setForeground(QBrush(QColor(0, 0, 0))) # Black color for normal text - font = QFont() - font.setItalic(False) - font.setBold(False) - file_name_item.setFont(font) - break - + item.setIcon(1, self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical)) + + def on_download_start(self, video_path): - for row in range(self.video_table.rowCount()): - if self.video_table.item(row, 0).text() == os.path.basename(video_path): - file_name_item = self.video_table.item(row, 0) - grey_color = QColor(200, 200, 200) # Light grey - file_name_item.setForeground(QBrush(grey_color)) - font = QFont() - font.setBold(True) - file_name_item.setFont(font) - break + item = self.find_item_by_path(os.path.basename(video_path)) + if item: + grey_color = QColor(200, 200, 200) # Light grey + item.setForeground(1, QBrush(grey_color)) + font = item.font(1) + font.setBold(True) + item.setFont(1, font) + item.setIcon(1, QIcon.fromTheme("emblem-synchronizing")) + else: + print(f"Cannot find item for {video_path}") - def play_video(self, row, column): - file_name_item = self.video_table.item(row, 0) - video_path = os.path.join(video_storage_dir, file_name_item.text()) + def play_video(self, file_name_item, column): + video_path = os.path.join(video_storage_dir, file_name_item.text(1)) - if file_name_item.font().italic() or file_name_item.foreground().color().lightness() >= 200: - print(f"Video {video_path} is not yet downloaded. Please wait.") + if file_name_item.font(1).italic() or file_name_item.foreground(1).color().lightness() >= 200: + print(f"Video {video_path} is not yet downloaded. Moving it to top of queue. Please wait for download.") + # Find the item in the download_queue that matches the base file name + found_item = None + for item in list(self.download_queue): + if item[1] == file_name_item.text(1): + found_item = item + break + + if found_item: + # Remove the item from its current position in the queue + self.download_queue.remove(found_item) + # Add the item to the end of the queue + self.download_thread.add_to_queue(*found_item, left=True) return + # Enable/disable GetHighRes button based on whether it's a sub stream + self.get_highres_button.setEnabled("RecS" in file_name_item.text(1)) + print(f"Playing video: {video_path}") url = QUrl.fromLocalFile(video_path) self.media_player.setSource(url) @@ -362,6 +491,28 @@ def start_playback(): # Timer needed to be able to play at seek offset in the video, otherwise setPosition seems ignored QTimer.singleShot(20, start_playback) + def get_highres_stream_for_file(self): + current_item = self.video_tree.currentItem() + if not current_item or "RecS" not in current_item.text(1): + return + + parsed_data = parse_filename(current_item.text(1)) + if not parsed_data: + print(f"Could not parse file {current_item.text(1)}") + return + + start_time = parsed_data['start_datetime'] - timedelta(seconds=1) + end_time = datetime.datetime.strptime(f"{parsed_data['start_datetime'].strftime('%Y%m%d')} {parsed_data['end_time']}", "%Y%m%d %H%M%S") + timedelta(seconds=1) + + main_files = self.cam.get_motion_files(start=start_time, end=end_time, streamtype='main', channel=parsed_data['channel']) + + if main_files: + for main_file in main_files: + self.add_video(main_file['filename']) + self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder) + else: + print(f"No main stream file found for {current_item.text(1)}") + def play_pause(self): if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: self.media_player.pause() @@ -408,7 +559,9 @@ def open_in_mpv(self): def closeEvent(self, event): self.download_thread.stop() - self.download_thread.wait() + self.download_thread.wait(1000) + self.download_thread.terminate() + self.cam.logout() super().closeEvent(event) def read_config(props_path: str) -> dict: @@ -430,13 +583,19 @@ def read_config(props_path: str) -> dict: def signal_handler(sig, frame): print("Exiting the application...") sys.exit(0) + cam.logout() + QApplication.quit() if __name__ == '__main__': signal.signal(signal.SIGINT, signal_handler) -# Read in your ip, username, & password -# (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure) + + parser = argparse.ArgumentParser(description="Reolink Video Review GUI") + parser.add_argument('--sub', action='http://github.com/store_true', help="Search for sub channel instead of main channel") + parser.add_argument('files', nargs='*', help="Optional video file names to process") + args = parser.parse_args() + config = read_config('camera.cfg') ip = config.get('camera', 'ip') @@ -449,14 +608,19 @@ def signal_handler(sig, frame): start = dt.combine(dt.now(), dt.min.time()) end = dt.now() - processed_motions = cam.get_motion_files(start=start, end=end, streamtype='main', channel=0) - processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + + streamtype = 'sub' if args.sub else 'main' + processed_motions = cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=0) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=1) start = dt.now() - timedelta(days=1) end = dt.combine(start, dt.max.time()) - processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=0) - processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=0) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=1) + + if len(processed_motions) == 0: + print("Camera did not return any video?!") video_files = [] for i, motion in enumerate(processed_motions): @@ -464,9 +628,9 @@ def signal_handler(sig, frame): print("Processing %s" % (fname)) video_files.append(fname) - video_files.extend(sys.argv[1:]) + video_files.extend([os.path.basename(file) for file in args.files]) app = QApplication(sys.argv) player = VideoPlayer(video_files) - player.resize(1900, 1000) + player.resize(2400, 1000) player.show() sys.exit(app.exec()) From b86642c9075c15ef753dd572a78df829e53aef38 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 8 Oct 2024 20:13:33 +0000 Subject: [PATCH 092/103] feat: custom session handler (#81) Co-authored-by: Josh Shaw --- examples/custom_ssl_session.py | 30 +++++++++++++++++++++++++++++ reolinkapi/handlers/api_handler.py | 1 + reolinkapi/handlers/rest_handler.py | 12 ++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 examples/custom_ssl_session.py diff --git a/examples/custom_ssl_session.py b/examples/custom_ssl_session.py new file mode 100644 index 0000000..bd13f5e --- /dev/null +++ b/examples/custom_ssl_session.py @@ -0,0 +1,30 @@ +from reolinkapi import Camera + +import urllib3 +import requests +from urllib3.util import create_urllib3_context + +class CustomSSLContextHTTPAdapter(requests.adapters.HTTPAdapter): + def __init__(self, ssl_context=None, **kwargs): + self.ssl_context = ssl_context + super().__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = urllib3.poolmanager.PoolManager( + num_pools=connections, maxsize=maxsize, + block=block, ssl_context=self.ssl_context) + +urllib3.disable_warnings() +ctx = create_urllib3_context() +ctx.load_default_certs() +ctx.set_ciphers("AES128-GCM-SHA256") +ctx.check_hostname = False + +session = requests.session() +session.adapters.pop("https://", None) +session.mount("https://", CustomSSLContextHTTPAdapter(ctx)) + +## Add a custom http handler to add in different ciphers that may +## not be aloud by default in openssl which urlib uses +cam = Camera("url", "user", "password", https=True, session=session) +cam.reboot_camera() diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index b9efe58..85bf8b5 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -58,6 +58,7 @@ def __init__(self, ip: str, username: str, password: str, https: bool = False, * self.username = username self.password = password Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found + Request.session = kwargs.get("session") # Defaults to None if key isn't found def login(self) -> bool: """ diff --git a/reolinkapi/handlers/rest_handler.py b/reolinkapi/handlers/rest_handler.py index 66f172a..a9512f4 100644 --- a/reolinkapi/handlers/rest_handler.py +++ b/reolinkapi/handlers/rest_handler.py @@ -4,6 +4,14 @@ class Request: proxies = None + session = None + + @staticmethod + def __getSession(): + reqHandler = requests + if Request.session is not None: + reqHandler = Request.session + return reqHandler @staticmethod def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \ @@ -17,7 +25,7 @@ def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None """ try: headers = {'content-type': 'application/json'} - r = requests.post(url, verify=False, params=params, json=data, headers=headers, + r = Request.__getSession().post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies) if r.status_code == 200: return r @@ -37,7 +45,7 @@ def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> O :return: """ try: - data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) + data = Request.__getSession().get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) return data except Exception as e: print("Get Error\n", e) From 85a89fe9164f25ed7049ef6ec4a7e1107915d116 Mon Sep 17 00:00:00 2001 From: RobertHolstein Date: Tue, 8 Oct 2024 16:16:09 -0400 Subject: [PATCH 093/103] feat: network settings and camera name (#82) --- examples/basic_usage.py | 1 + examples/network_config.py | 59 ++++++++++++++++++++++++++++++++++++ reolinkapi/mixins/device.py | 19 ++++++++++++ reolinkapi/mixins/network.py | 36 ++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 examples/network_config.py diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 0ba744c..1fa5bdd 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -9,3 +9,4 @@ dst = cam.get_dst() ok = cam.add_user("foo", "bar", "admin") alarm = cam.get_alarm_motion() + cam.set_device_name(name='my_camera') \ No newline at end of file diff --git a/examples/network_config.py b/examples/network_config.py new file mode 100644 index 0000000..fa5eaf4 --- /dev/null +++ b/examples/network_config.py @@ -0,0 +1,59 @@ +import os +from configparser import RawConfigParser +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('camera.cfg') + +ip = config.get('camera', 'ip') +un = config.get('camera', 'username') +pw = config.get('camera', 'password') + +# Connect to camera +cam = Camera(ip, un, pw) + +# Get current network settings +current_settings = cam.get_network_general() +print("Current settings:", current_settings) + +# Configure DHCP +cam.set_network_settings( + ip="", + gateway="", + mask="", + dns1="", + dns2="", + mac=current_settings[0]['value']['LocalLink']['mac'], + use_dhcp=True, + auto_dns=True +) + +# Configure static IP +# cam.set_network_settings( +# ip="192.168.1.102", +# gateway="192.168.1.1", +# mask="255.255.255.0", +# dns1="8.8.8.8", +# dns2="8.8.4.4", +# mac=current_settings[0]['value']['LocalLink']['mac'], +# use_dhcp=False, +# auto_dns=False +# ) \ No newline at end of file diff --git a/reolinkapi/mixins/device.py b/reolinkapi/mixins/device.py index 684be45..21b7961 100644 --- a/reolinkapi/mixins/device.py +++ b/reolinkapi/mixins/device.py @@ -5,6 +5,25 @@ class DeviceAPIMixin: """API calls for getting device information.""" DEFAULT_HDD_ID = [0] + def set_device_name(self, name: str) -> bool: + """ + Set the device name of the camera. + :param name: The new name for the device + :return: bool indicating success + """ + body = [{"cmd": "SetDevName", "action": 0, "param": {"DevName": {"name": name}}}] + self._execute_command('SetDevName', body) + print(f"Successfully set device name to: {name}") + return True + + def get_device_name(self) -> Dict: + """ + Get the device name of the camera. + :return: Dict containing the device name + """ + body = [{"cmd": "GetDevName", "action": 0, "param": {}}] + return self._execute_command('GetDevName', body) + def get_hdd_info(self) -> Dict: """ Gets all HDD and SD card information from Camera diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py index f4fe4a6..fc4bd7a 100644 --- a/reolinkapi/mixins/network.py +++ b/reolinkapi/mixins/network.py @@ -3,6 +3,42 @@ class NetworkAPIMixin: """API calls for network settings.""" + def set_network_settings(self, ip: str, gateway: str, mask: str, dns1: str, dns2: str, mac: str, + use_dhcp: bool = True, auto_dns: bool = True) -> Dict: + """ + Set network settings including IP, gateway, subnet mask, DNS, and connection type (DHCP or Static). + + :param ip: str + :param gateway: str + :param mask: str + :param dns1: str + :param dns2: str + :param mac: str + :param use_dhcp: bool + :param auto_dns: bool + :return: Dict + """ + body = [{"cmd": "SetLocalLink", "action": 0, "param": { + "LocalLink": { + "dns": { + "auto": 1 if auto_dns else 0, + "dns1": dns1, + "dns2": dns2 + }, + "mac": mac, + "static": { + "gateway": gateway, + "ip": ip, + "mask": mask + }, + "type": "DHCP" if use_dhcp else "Static" + } + }}] + + return self._execute_command('SetLocalLink', body) + print("Successfully Set Network Settings") + return True + 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: """ From d8ed8585f61611497d2d6a37976600d82b370e6e Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Tue, 8 Oct 2024 22:35:18 +0200 Subject: [PATCH 094/103] chore: version bump 0.3.0 (#83) Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- reolinkapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 1ded166..0c1b739 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.2.0" +__version__ = "0.3.0" From 71f206a9f3ea3850c83ce3273faaaff8ed76cf62 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:24:18 +0100 Subject: [PATCH 095/103] feat: python lint and build action (#87) Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- .github/workflows/python-package.yaml | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/python-package.yaml diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml new file mode 100644 index 0000000..a3e903c --- /dev/null +++ b/.github/workflows/python-package.yaml @@ -0,0 +1,43 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest build + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Build + run: | + python -m build + # - name: Test with pytest + # run: | + # pytest From 8cacb4784b7cde8963d65520d4762828eb178a20 Mon Sep 17 00:00:00 2001 From: RobertHolstein Date: Mon, 9 Dec 2024 07:30:53 -0500 Subject: [PATCH 096/103] Add Set NTP (#84) * Add set NTP * chore: convert bool to int --------- Co-authored-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- examples/network_config.py | 3 +++ reolinkapi/mixins/network.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/examples/network_config.py b/examples/network_config.py index fa5eaf4..51b1956 100644 --- a/examples/network_config.py +++ b/examples/network_config.py @@ -30,6 +30,9 @@ def read_config(props_path: str) -> dict: # Connect to camera cam = Camera(ip, un, pw) +# Set NTP +cam.set_ntp(enable=True, interval=1440, port=123, server="time-b.nist.gov") + # Get current network settings current_settings = cam.get_network_general() print("Current settings:", current_settings) diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py index fc4bd7a..ae44ba4 100644 --- a/reolinkapi/mixins/network.py +++ b/reolinkapi/mixins/network.py @@ -72,6 +72,27 @@ def set_wifi(self, ssid: str, password: str) -> Dict: }}}] return self._execute_command('SetWifi', body) + def set_ntp(self, enable: bool = True, interval: int = 1440, port: int = 123, server: str = "pool.ntp.org") -> Dict: + """ + Set NTP settings. + + :param enable: bool + :param interval: int + :param port: int + :param server: str + :return: Dict + """ + body = [{"cmd": "SetNtp", "action": 0, "param": { + "Ntp": { + "enable": int(enable), + "interval": interval, + "port": port, + "server": server + }}}] + response = self._execute_command('SetNtp', body) + print("Successfully Set NTP Settings") + return response + def get_net_ports(self) -> Dict: """ Get network ports From b0bcb9f7085163956e70ed2da52d6a99511dfff5 Mon Sep 17 00:00:00 2001 From: Sven337 Date: Mon, 9 Dec 2024 13:32:52 +0100 Subject: [PATCH 097/103] Video review: minor improvements (#85) * when getting highres, remove corresponding sub stream and re-parent tree * use sub instead of main by default now that GetHighRes is there * some file sizes are 0 (recording in progress?) --- examples/video_review_gui.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py index 011d8eb..6327dca 100644 --- a/examples/video_review_gui.py +++ b/examples/video_review_gui.py @@ -56,7 +56,7 @@ def parse_filename(file_name): # https://github.com/sven337/ReolinkLinux/wiki/Figuring-out-the-file-names#file-name-structure pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_Rec[MS](\d)(\d)_(DST)?(\d{8})_(\d{6})_(\d{6})' v3_suffix = r'.*_(\w{4,8})_(\w{4,8})\.mp4' - v9_suffix = r'.*_(\d)_(\w{7})(\w{7})_(\w{4,8})\.mp4' + v9_suffix = r'.*_(\d)_(\w{7})(\w{7})_(\w{1,8})\.mp4' match = re.match(pattern, file_name) out = {} @@ -375,8 +375,28 @@ def add_video(self, video_path): # Find a potentially pre-existing channel0 item for this datetime, if so, add as a child # This lets channel1 appear as a child, but also main & sub videos appear in the same group channel_0_item = self.find_channel_0_item(start_datetime) + if channel_0_item: - channel_0_item.addChild(video_item) + # Check if the current item is a main stream and the existing channel_0_item is a sub stream + if "RecM" in base_file_name and "RecS" in channel_0_item.text(1): + # Make the current main stream item the new parent + new_parent = video_item + # Move all children of the sub stream item to the new main stream item + while channel_0_item.childCount() > 0: + child = channel_0_item.takeChild(0) + new_parent.addChild(child) + # Remove the old sub stream item + parent = channel_0_item.parent() + if parent: + parent.removeChild(channel_0_item) + else: + index = self.video_tree.indexOfTopLevelItem(channel_0_item) + self.video_tree.takeTopLevelItem(index) + # Add the new main stream item as a top-level item + self.video_tree.addTopLevelItem(new_parent) + else: + # If it's not a main stream replacing a sub stream, add as a child as before + channel_0_item.addChild(video_item) else: self.video_tree.addTopLevelItem(video_item) @@ -424,6 +444,7 @@ def on_download_complete(self, video_path, success): item = self.find_item_by_path(os.path.basename(video_path)) if not item: print(f"on_download_complete {video_path} did not find item?!") + return item.setForeground(1, QBrush(QColor(0, 0, 0))) # Black color for normal text font = item.font(1) font.setItalic(False) @@ -592,7 +613,7 @@ def signal_handler(sig, frame): signal.signal(signal.SIGINT, signal_handler) parser = argparse.ArgumentParser(description="Reolink Video Review GUI") - parser.add_argument('--sub', action='http://github.com/store_true', help="Search for sub channel instead of main channel") + parser.add_argument('--main', action='http://github.com/store_true', help="Search for main channel instead of sub channel") parser.add_argument('files', nargs='*', help="Optional video file names to process") args = parser.parse_args() @@ -609,7 +630,7 @@ def signal_handler(sig, frame): start = dt.combine(dt.now(), dt.min.time()) end = dt.now() - streamtype = 'sub' if args.sub else 'main' + streamtype = 'sub' if not args.main else 'main' processed_motions = cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=0) processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=1) From ba170deabd189913fff7ccae2f3714471133b17d Mon Sep 17 00:00:00 2001 From: Sven337 Date: Wed, 11 Dec 2024 11:11:03 +0100 Subject: [PATCH 098/103] download: use the Playback command instead of Download (#86) On my TrackMix Wifi, both API commands work the same way, but Download is limited to about 800kB/s, while Playback hits about 4MB/s. So, replace the Download command with Playback by default for get_file. The "method" optional argument can be used to switch back to Download for people who need it. --- reolinkapi/handlers/api_handler.py | 2 +- reolinkapi/mixins/download.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 85bf8b5..2ef7464 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -121,7 +121,7 @@ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) try: if self.token is None: raise ValueError("Login first") - if command == 'Download': + if command == 'Download' or command == 'Playback': # Special handling for downloading an mp4 # Pop the filepath from data tgt_filepath = data[0].pop('filepath') diff --git a/reolinkapi/mixins/download.py b/reolinkapi/mixins/download.py index ebd1603..3580930 100644 --- a/reolinkapi/mixins/download.py +++ b/reolinkapi/mixins/download.py @@ -1,18 +1,21 @@ class DownloadAPIMixin: """API calls for downloading video files.""" - def get_file(self, filename: str, output_path: str) -> bool: + def get_file(self, filename: str, output_path: str, method = 'Playback') -> bool: """ Download the selected video file + On at least Trackmix Wifi, it was observed that the Playback method + yields much improved download speeds over the Download method, for + unknown reasons. :return: response json """ body = [ { - "cmd": "Download", + "cmd": method, "source": filename, "output": filename, "filepath": output_path } ] - resp = self._execute_command('Download', body) + resp = self._execute_command(method, body) return resp From 53e053babefe276cafa7fea3db13e04915ea282a Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:16:29 +0100 Subject: [PATCH 099/103] release: version 0.4.0 (#88) Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- reolinkapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 0c1b739..28945de 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.3.0" +__version__ = "0.4.0" From 058380f2be7e9173a04b6ea647b0b13cc99fbcd8 Mon Sep 17 00:00:00 2001 From: Steve Briskin Date: Thu, 23 Jan 2025 01:52:28 -0500 Subject: [PATCH 100/103] feature: add get zoom/focus, discrete set zoom/focus commands, and get/set autofocus (#89) * zoom and focus commands * get and set autofocus * fix SetAutoFocus api call * update readme --- README.md | 5 +++-- reolinkapi/mixins/zoom.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1b9c651..87a205f 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,8 @@ GET: - [X] User -> Manage User - [X] Device -> HDD/SD Card - [x] PTZ -> Presets, Calibration Status -- [ ] Zoom -- [ ] Focus +- [x] Zoom +- [x] Focus - [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) - [X] Image Data -> "Snap" Frame from Video Stream @@ -152,6 +152,7 @@ do not work and is not supported here. - RLC-410-5MP - RLC-510A - RLC-520 +- RLC-823A - C1-Pro - D400 - E1 Zoom diff --git a/reolinkapi/mixins/zoom.py b/reolinkapi/mixins/zoom.py index 0f5778d..74549e0 100644 --- a/reolinkapi/mixins/zoom.py +++ b/reolinkapi/mixins/zoom.py @@ -16,6 +16,31 @@ def _stop_zooming_or_focusing(self) -> Dict: data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}] return self._execute_command('PtzCtrl', data) + def get_zoom_focus(self) -> Dict: + """This command returns the current zoom and focus values.""" + data = [{"cmd": "GetZoomFocus", "action": 0, "param": {"channel": 0}}] + return self._execute_command('GetZoomFocus', data) + + def start_zoom_pos(self, position: float) -> Dict: + """This command sets the zoom position.""" + data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "ZoomPos", "pos": position}}}] + return self._execute_command('StartZoomFocus', data) + + def start_focus_pos(self, position: float) -> Dict: + """This command sets the focus position.""" + data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "FocusPos", "pos": position}}}] + return self._execute_command('StartZoomFocus', data) + + def get_auto_focus(self) -> Dict: + """This command returns the current auto focus status.""" + data = [{"cmd": "GetAutoFocus", "action": 0, "param": {"channel": 0}}] + return self._execute_command('GetAutoFocus', data) + + def set_auto_focus(self, disable: bool) -> Dict: + """This command sets the auto focus status.""" + data = [{"cmd": "SetAutoFocus", "action": 0, "param": {"AutoFocus": {"channel": 0, "disable": 1 if disable else 0}}}] + return self._execute_command('SetAutoFocus', data) + def start_zooming_in(self, speed: float = 60) -> Dict: """ The camera zooms in until self.stop_zooming() is called. From e0fee8d9d31428791ff447a59f6fdf9d1e7d0682 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Thu, 23 Jan 2025 07:57:39 +0100 Subject: [PATCH 101/103] release: version 0.4.1 (#90) Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- reolinkapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 28945de..ce35f76 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.4.0" +__version__ = "0.4.1" From 336af8e0308b1cf0f5f7e02f9e58eaca12e80d72 Mon Sep 17 00:00:00 2001 From: Dennis Hemker Date: Thu, 19 Jun 2025 11:47:29 +0200 Subject: [PATCH 102/103] Fix safe encoding for passwords with exclamation mark (#93) Co-authored-by: Dennis Hemker --- reolinkapi/mixins/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 3b3a516..76dc239 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -40,7 +40,7 @@ def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: 'user': self.username, 'password': self.password, } - parms = parse.urlencode(data).encode("utf-8") + parms = parse.urlencode(data, safe="!").encode("utf-8") try: response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout) From 87cd38263a37bf859f264ced18876ead6a55a1bf Mon Sep 17 00:00:00 2001 From: Matt <50885599+MJPye@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:49:51 +0200 Subject: [PATCH 103/103] Add PtzPreset to SetPtzPreset POST data (#94) --- reolinkapi/mixins/ptz.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reolinkapi/mixins/ptz.py b/reolinkapi/mixins/ptz.py index 17ed2cb..fd11dfc 100644 --- a/reolinkapi/mixins/ptz.py +++ b/reolinkapi/mixins/ptz.py @@ -51,8 +51,8 @@ def _send_noparm_operation(self, operation: str) -> Dict: return self._execute_command('PtzCtrl', data) 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}}] + data = [{"cmd": "SetPtzPreset", "action": 0, "param": { "PtzPreset": { + "channel": 0, "enable": enable, "id": preset, "name": name}}}] return self._execute_command('PtzCtrl', data) def go_to_preset(self, speed: float = 60, index: float = 1) -> Dict: