From f003a0dc586e24b9cb6b4bd815a1c348384db5e3 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Tue, 14 Feb 2023 12:10:41 -0500 Subject: [PATCH 01/11] Update setup.py Loosen restrictions. --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 8166180..13649de 100644 --- a/setup.py +++ b/setup.py @@ -32,9 +32,9 @@ def find_version(*file_paths): ] EXTRAS_REQUIRE = { 'streaming': [ - 'numpy==1.19.4', - 'opencv-python==4.4.0.46', - 'Pillow==8.0.1', + 'numpy>=1.19.4', + 'opencv-python>=4.4.0.46', + 'Pillow>=8.0.1', ], } From eb8add13ad915176eba8fde7b823b723ad0634f3 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Tue, 14 Feb 2023 12:12:54 -0500 Subject: [PATCH 02/11] Update streaming_video.py Improvements to share common code. Fix threaded version to open window in main thread. --- examples/streaming_video.py | 110 ++++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 42 deletions(-) mode change 100644 => 100755 examples/streaming_video.py diff --git a/examples/streaming_video.py b/examples/streaming_video.py old mode 100644 new mode 100755 index 9049ed8..0704c2f --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -1,54 +1,65 @@ +#!/opt/homebrew/bin/python3 + import cv2 +from queue import Queue +from sys import argv from reolinkapi import Camera +_resize = 1024 + +def display_frame(cam, frame, count): + if frame is not None: + print("Frame %5d" % count, end='\r') + cv2.imshow(cam.ip, frame) + + key = cv2.waitKey(1) + if key == ord('q') or key == ord('Q') or key == 27: + cam.stop_stream() + cv2.destroyAllWindows() + return False + + return True -def non_blocking(): - print("calling non-blocking") +def non_blocking(cam): + frames = Queue(maxsize=30) - 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) + def inner_callback(frame): + if _resize > 10: + frames.put(maintain_aspect_ratio_resize(frame, width=_resize)) + else: + frames.put(frame.copy()) - c = Camera("192.168.1.112", "admin", "jUa2kUzi") # t in this case is a thread - t = c.open_video_stream(callback=inner_callback) + t = cam.open_video_stream(callback=inner_callback) - print(t.is_alive()) - while True: - if not t.is_alive(): - print("continuing") + counter = 0 + + while t.is_alive(): + frame = frames.get() + counter = counter+1 + + if not display_frame(cam, frame, counter): break - # stop the stream - # client.stop_stream() + + if __debug__ and frames.qsize() > 1: + # Can't consume frames fast enough?? + print("\nQueue: %d" % frames.qsize()) + -def blocking(): - c = Camera("192.168.1.112", "admin", "jUa2kUzi") +def blocking(cam): # 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) + stream = cam.open_video_stream() + counter = 0 + + for frame in stream: + if _resize > 10: + frame = maintain_aspect_ratio_resize(frame, width=_resize) + + counter = counter + 1 + + if not display_frame(cam, frame, counter): + break; # Resizes a image and maintains aspect ratio @@ -76,6 +87,21 @@ def maintain_aspect_ratio_resize(image, width=None, height=None, inter=cv2.INTER return cv2.resize(image, dim, interpolation=inter) -# Call the methods. Either Blocking (using generator) or Non-Blocking using threads -# non_blocking() -blocking() + +if __name__ == "__main__": + if len(argv) != 2: + print(f"Usage: {argv[0]} ") + exit(1) + + try: + host = f"darknet{argv[1]}" + cam = Camera(host, username="rtsp", password="darknet") + except: + print(f"Failed to open camera: {host}") + exit(1) + + # Call the methods. Either Blocking (using generator) or Non-Blocking using threads + non_blocking(cam) + # blocking(cam) + + print("\nDone.") From ee7dde6de304441af701719987983506586539d2 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Tue, 14 Feb 2023 13:08:35 -0500 Subject: [PATCH 03/11] Update stream.py Forgot to commit with previous. New method: stop_stream. --- reolinkapi/mixins/stream.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 6798a42..b0d7ed6 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -10,6 +10,8 @@ from PIL.Image import Image, open as open_image from reolinkapi.utils.rtsp_client import RtspClient + def __init__(self): + self.rtsp_client = None class StreamAPIMixin: @@ -22,9 +24,13 @@ def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any: :param callback: :param proxies: Default is none, example: {"host": "localhost", "port": 8000} """ - rtsp_client = RtspClient( + self.rtsp_client = RtspClient( ip=self.ip, username=self.username, password=self.password, profile=self.profile, proxies=proxies, callback=callback) - return rtsp_client.open_stream() + return self.rtsp_client.open_stream() + + def stop_stream(self): + if self.rtsp_client: + self.rtsp_client.stop_stream() def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: """ From da83de9236dbea966f3a8a566fd1de1d48a5230d Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Tue, 14 Feb 2023 13:10:46 -0500 Subject: [PATCH 04/11] Add check for login failure. New camera method: is_logged_in --- examples/streaming_video.py | 6 +++++- reolinkapi/handlers/api_handler.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/streaming_video.py b/examples/streaming_video.py index 0704c2f..9a449e0 100755 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -94,12 +94,16 @@ def maintain_aspect_ratio_resize(image, width=None, height=None, inter=cv2.INTER exit(1) try: - host = f"darknet{argv[1]}" + host = f"watchdog{argv[1]}" cam = Camera(host, username="rtsp", password="darknet") except: print(f"Failed to open camera: {host}") exit(1) + if not cam.is_logged_in(): + print(f"Login failed for {host}") + exit(1) + # Call the methods. Either Blocking (using generator) or Non-Blocking using threads non_blocking(cam) # blocking(cam) diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 501ceb7..317e4d2 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -85,7 +85,10 @@ def login(self) -> bool: return False except Exception as e: print("Error Login\n", e) - raise + return False + + def is_logged_in(self) -> bool: + return self.token is not None def logout(self) -> bool: """ From ea70bc7fb7e42508b9de8f69a1084b8402f580ca Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Tue, 14 Feb 2023 13:12:04 -0500 Subject: [PATCH 05/11] New api method "set_network_ftp" New example program to set ftp parameters. --- examples/set_ftp.py | 44 ++++++++++++++++++++++++++++++++++ reolinkapi/mixins/network.py | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 examples/set_ftp.py diff --git a/examples/set_ftp.py b/examples/set_ftp.py new file mode 100644 index 0000000..8c53dd3 --- /dev/null +++ b/examples/set_ftp.py @@ -0,0 +1,44 @@ +#!/opt/homebrew/bin/python3 + +import reolinkapi +import os +import json +from sys import argv + +def ftp_cam(which): + hostname = 'watchdog%d' % which + cam_name = 'Watchdog%d' % which + + try: + cam = reolinkapi.Camera(hostname, username="userid", password="passwd") + except: + print(f"Failed to open camera {cam_name}") + return 1 + + if not cam.is_logged_in(): + print(f"Login failed for {cam_name}") + return 2 + + try: + print(f"Setting FTP params for {cam_name}") + response = cam.set_network_ftp("hocus", "pocus", "directory", "192.168.1.2", 0) + print( json.dumps( response, indent=4 ) ) + print( json.dumps( cam.get_network_ftp(), indent=4 ) ) + if response[0]["value"]["rspCode"] == 200: + print("Success!") + except Exception as e: + print(f"{argv[0]} for {cam_name} failed.") + print(e) + return 3 + + return 0 + + +if __name__ == "__main__": + if len(argv) != 2: + print("Usage: %s ]" % argv[0]) + exit(1) + + exit( ftp_cam(int(argv[1])) ) + + diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py index f4fe4a6..14c88a9 100644 --- a/reolinkapi/mixins/network.py +++ b/reolinkapi/mixins/network.py @@ -28,6 +28,52 @@ def set_net_port(self, http_port: float = 80, https_port: float = 443, media_por print("Successfully Set Network Ports") return True + def set_network_ftp(self, username, password, directory, server_ip, enable) -> Dict: + """ + Set the camera FTP network information + { + "cmd": "GetFtp", + "code": 0, + "value": { + "Ftp": { + "anonymous": 0, + "interval": 15, + "maxSize": 100, + "mode": 0, + "password": "***********", + "port": 21, + "remoteDir": "incoming1", + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "server": "192.168.1.2", + "streamType": 0, + "userName": "ftpuser" + } + } + } + :return: response + """ + body = [ + { + "cmd": "SetFtp", + "action": 0, + "param": { + "Ftp": { + "password": password, + "remoteDir": directory, + "server": server_ip, + "userName": username, + "schedule": { + "enable": enable + } + } + } + } + ] + return self._execute_command('SetFtp', body) + def set_wifi(self, ssid: str, password: str) -> Dict: body = [{"cmd": "SetWifi", "action": 0, "param": { "Wifi": { From f2a01d54d15d0d9ccf3153ea9c28dfd159443d48 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Tue, 14 Feb 2023 13:13:21 -0500 Subject: [PATCH 06/11] Two new example programs get_info calls all the info methods. snap will snap a single frame image. --- examples/get_info.py | 34 ++++++++++++++++++++++++++++++++++ examples/snap.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 examples/get_info.py create mode 100755 examples/snap.py diff --git a/examples/get_info.py b/examples/get_info.py new file mode 100644 index 0000000..420f0f7 --- /dev/null +++ b/examples/get_info.py @@ -0,0 +1,34 @@ +import reolinkapi +import json + +if __name__ == "__main__": + hostname = "watchdog1" + + try: + cam = reolinkapi.Camera(hostname, username="userid", password="passwd") + except: + print(f"Failed to open camera {hostname}") + exit(1) + + if not cam.is_logged_in(): + print(f"Login failed for {hostname}") + exit(1) + + + print( json.dumps( cam.get_information(), indent=4 ) ) + print( json.dumps( cam.get_network_general(), indent=4 ) ) + print( json.dumps( cam.get_network_ddns(), indent=4 ) ) + print( json.dumps( cam.get_network_ntp(), indent=4 ) ) + print( json.dumps( cam.get_network_email(), indent=4 ) ) + print( json.dumps( cam.get_network_ftp(), indent=4 ) ) + print( json.dumps( cam.get_network_push(), indent=4 ) ) + print( json.dumps( cam.get_network_status(), indent=4 ) ) + print( json.dumps( cam.get_recording_encoding(), indent=4 ) ) + print( json.dumps( cam.get_recording_advanced(), indent=4 ) ) + print( json.dumps( cam.get_general_system(), indent=4 ) ) + print( json.dumps( cam.get_performance(), indent=4 ) ) + print( json.dumps( cam.get_dst(), indent=4 ) ) + print( json.dumps( cam.get_online_user(), indent=4 ) ) + print( json.dumps( cam.get_users(), indent=4 ) ) + + diff --git a/examples/snap.py b/examples/snap.py new file mode 100755 index 0000000..c2c945b --- /dev/null +++ b/examples/snap.py @@ -0,0 +1,41 @@ +#!/opt/homebrew/bin/python3 + +import reolinkapi +import os +from sys import argv + +def snap_cam(which, dirname): + hostname = 'watchdog%d' % which + cam_name = 'Watchdog%d' % which + + try: + cam = reolinkapi.Camera(hostname, username="rtsp", password="darknet") + except: + print(f"Failed to open camera {cam_name}") + return 1 + + if not cam.is_logged_in(): + print(f"Login failed for {cam_name}") + return 2 + + image_file = '%s/%s.jpg' % (dirname, hostname) + if os.path.exists(image_file): + os.unlink(image_file) + + print("Snapping", cam_name) + image = cam.get_snap() + image.save(image_file) + return 0 + + +if __name__ == "__main__": + dirname = '.' + if len(argv) == 3: + dirname = argv[2] + elif len(argv) != 2: + print("Usage: %s [output-directory]" % argv[0]) + exit(1) + + exit( snap_cam(int(argv[1]), dirname) ) + + From e61718a0da1e42fc8959c9b59c26f4d3fccd1b56 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Thu, 16 Feb 2023 09:46:33 -0500 Subject: [PATCH 07/11] Update api_handler.py Getting occasional garbage back from the cameras which causes the json conversion to crap out. Print some info when this happens. --- reolinkapi/handlers/api_handler.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 317e4d2..37597a1 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -138,7 +138,17 @@ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) else: response = Request.post(self.url, data=data, params=params) - return response.json() + # print(f"Command: {command}, Response: {response.text}") + response.raise_for_status() # raises exception when not a 2xx response + if response.status_code != 204: + try: + return response.json() + except Exception as e: + print(f"JSON failure for {command}; Respose: ", response.text) + raise e + + raise RuntimeError(f"Unexpected response code {response.status_code}") + except Exception as e: print(f"Command {command} failed: {e}") raise From 1f62a2423b4096940622e63f45d50f91c2fcfc71 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Sun, 5 Mar 2023 18:04:01 -0500 Subject: [PATCH 08/11] Add support for newer API version on some cameras. The GetAbility command returns a property named "scheduleVersion" which indicates which API calls should be used. The new ones have a "V20" tacked onto the command name. --- reolinkapi/handlers/api_handler.py | 17 +++++++++++++++-- reolinkapi/mixins/network.py | 21 +++++++++++++++------ reolinkapi/mixins/record.py | 14 ++++++++++---- reolinkapi/mixins/system.py | 8 ++++++++ 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 317e4d2..fa6de22 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -1,4 +1,6 @@ import requests +from urllib3.exceptions import InsecureRequestWarning + from typing import Dict, List, Optional, Union from reolinkapi.mixins.alarm import AlarmAPIMixin from reolinkapi.mixins.device import DeviceAPIMixin @@ -53,6 +55,8 @@ def __init__(self, ip: str, username: str, password: str, https: bool = False, * self.url = f"{scheme}://{ip}/cgi-bin/api.cgi" self.ip = ip self.token = None + self.ability = None + self.scheduleVersion = 0 self.username = username self.password = password Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found @@ -64,6 +68,9 @@ def login(self) -> bool: :return: bool """ try: + # Suppress only the single warning from urllib3 needed. + requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) + body = [{"cmd": "Login", "action": 0, "param": {"User": {"userName": self.username, "password": self.password}}}] param = {"cmd": "Login", "token": "null"} @@ -73,9 +80,15 @@ def login(self) -> bool: code = data["code"] if int(code) == 0: self.token = data["value"]["Token"]["name"] - print("Login success") + # print("Login success") + ability = self.get_ability() + self.ability = ability[0]["value"]["Ability"] + self.scheduleVersion = self.ability["scheduleVersion"]["ver"] + print("API VERSION: ", self.scheduleVersion) return True - print(self.token) + + # print(self.token) + print("ERROR: LOGIN RESPONSE: ", response.text) return False else: # TODO: Verify this change w/ owner. Delete old code if acceptable. diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py index 14c88a9..bb62757 100644 --- a/reolinkapi/mixins/network.py +++ b/reolinkapi/mixins/network.py @@ -134,8 +134,11 @@ def get_network_email(self) -> Dict: See examples/response/GetNetworkEmail.json for example response data. :return: response json """ - body = [{"cmd": "GetEmail", "action": 0, "param": {}}] - return self._execute_command('GetEmail', body) + cmd = "GetEmail" + if self.scheduleVersion == 1: + cmd = "GetEmailV20" + body = [{"cmd": cmd, "action": 0, "param": {}}] + return self._execute_command(cmd, body) def get_network_ftp(self) -> Dict: """ @@ -143,8 +146,11 @@ def get_network_ftp(self) -> Dict: See examples/response/GetNetworkFtp.json for example response data. :return: response json """ - body = [{"cmd": "GetFtp", "action": 0, "param": {}}] - return self._execute_command('GetFtp', body) + cmd = "GetFtp" + if self.scheduleVersion == 1: + cmd = "GetFtpV20" + body = [{"cmd": cmd, "action": 0, "param": {}}] + return self._execute_command(cmd, body) def get_network_push(self) -> Dict: """ @@ -152,8 +158,11 @@ def get_network_push(self) -> Dict: See examples/response/GetNetworkPush.json for example response data. :return: response json """ - body = [{"cmd": "GetPush", "action": 0, "param": {}}] - return self._execute_command('GetPush', body) + cmd = "GetPush" + if self.scheduleVersion == 1: + cmd = "GetPushV20" + body = [{"cmd": cmd, "action": 0, "param": {}}] + return self._execute_command(cmd, body) def get_network_status(self) -> Dict: """ diff --git a/reolinkapi/mixins/record.py b/reolinkapi/mixins/record.py index 375b5ca..232ae99 100644 --- a/reolinkapi/mixins/record.py +++ b/reolinkapi/mixins/record.py @@ -19,8 +19,11 @@ def get_recording_advanced(self) -> Dict: 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) + cmd = "GetRec" + if self.scheduleVersion == 1: + cmd = "GetRecV20" + body = [{"cmd": cmd, "action": 1, "param": {"channel": 0}}] + return self._execute_command(cmd, body) def set_recording_encoding(self, audio: float = 0, @@ -45,9 +48,12 @@ def set_recording_encoding(self, :param sub_size: string Fluent Size :return: response """ + cmd = "SetRec" + if self.scheduleVersion == 1: + cmd = "SetRecV20" body = [ { - "cmd": "SetEnc", + "cmd": cmd, "action": 0, "param": { "Enc": { @@ -69,4 +75,4 @@ def set_recording_encoding(self, } } ] - return self._execute_command('SetEnc', body) + return self._execute_command(cmd, body) diff --git a/reolinkapi/mixins/system.py b/reolinkapi/mixins/system.py index dcb590a..577f486 100644 --- a/reolinkapi/mixins/system.py +++ b/reolinkapi/mixins/system.py @@ -8,6 +8,14 @@ def get_general_system(self) -> Dict: body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}] return self._execute_command('get_general_system', body, multi=True) + def get_ability(self) -> Dict: + """ + Get a the users capability set. We need this to know which API calls to use. + :return: response json + """ + body = [{"cmd": "GetAbility", "action": 0, "param": {"User": {"userName": ""}}}] + return self._execute_command('GetAbility', body) + def get_performance(self) -> Dict: """ Get a snapshot of the current performance of the camera. From 247a19cba22c1e40c0e60e56592e58dd6b52e454 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Wed, 11 Oct 2023 17:54:19 -0400 Subject: [PATCH 09/11] Add some support for V2 API. Misc fixes and additions. Improve set_osd call. Extend FTP setting support. Add set_network_ntp Add set_network_email Fixes to add_user and modify_user. --- reolinkapi/handlers/api_handler.py | 43 ++++++------ reolinkapi/mixins/display.py | 18 +++-- reolinkapi/mixins/network.py | 109 ++++++++++++++++++++++++++--- reolinkapi/mixins/user.py | 27 +++++-- 4 files changed, 152 insertions(+), 45 deletions(-) diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 56e8c5a..bd75a0e 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -76,20 +76,16 @@ def login(self) -> bool: param = {"cmd": "Login", "token": "null"} response = Request.post(self.url, data=body, params=param) if response is not None: + # print("LOGIN GOT: ", response.text) data = response.json()[0] code = data["code"] if int(code) == 0: self.token = data["value"]["Token"]["name"] # print("Login success") - ability = self.get_ability() - self.ability = ability[0]["value"]["Ability"] - self.scheduleVersion = self.ability["scheduleVersion"]["ver"] - print("API VERSION: ", self.scheduleVersion) - return True - - # print(self.token) - print("ERROR: LOGIN RESPONSE: ", response.text) - return False + else: + # print(self.token) + print("ERROR: LOGIN RESPONSE: ", response.text) + 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. @@ -97,9 +93,20 @@ def login(self) -> bool: print("Failed to login\nResponse was null.") return False except Exception as e: - print("Error Login\n", e) + print(f"ERROR Login Failed, exception: {e}") return False + try: + ability = self.get_ability() + self.ability = ability[0]["value"]["Ability"] + self.scheduleVersion = self.ability["scheduleVersion"]["ver"] + print("API VERSION: ", self.scheduleVersion) + except Exception as e: + self.logout() + return False + + return True + def is_logged_in(self) -> bool: return self.token is not None @@ -114,7 +121,7 @@ def logout(self) -> bool: # print(ret) return True except Exception as e: - print("Error Logout\n", e) + print(f"ERROR Logout Failed, exception: {e}") return False def _execute_command(self, command: str, data: List[Dict], multi: bool = False) -> \ @@ -151,17 +158,7 @@ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) else: response = Request.post(self.url, data=data, params=params) - # print(f"Command: {command}, Response: {response.text}") - response.raise_for_status() # raises exception when not a 2xx response - if response.status_code != 204: - try: - return response.json() - except Exception as e: - print(f"JSON failure for {command}; Respose: ", response.text) - raise e - - raise RuntimeError(f"Unexpected response code {response.status_code}") - + return response.json() except Exception as e: - print(f"Command {command} failed: {e}") + print(f"ERROR Command {command} failed, exception: {e}") raise diff --git a/reolinkapi/mixins/display.py b/reolinkapi/mixins/display.py index c44c04b..6652557 100644 --- a/reolinkapi/mixins/display.py +++ b/reolinkapi/mixins/display.py @@ -1,4 +1,5 @@ from typing import Dict +import json class DisplayAPIMixin: @@ -22,8 +23,8 @@ 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: float = 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 = 1, + osd_channel_name: str = "Camera", osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 1, osd_time_pos: str = "Lower Right", osd_watermark_enabled: bool = 0) -> bool: """ Set OSD @@ -38,18 +39,21 @@ def set_osd(self, bg_color: bool = 0, channel: float = 0, osd_channel_enabled: b ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] :return: whether the action was successful """ - body = [{"cmd": "SetOsd", "action": 1, + body = [{"cmd": "SetOsd", # "action": 1, "param": { "Osd": { - "bgcolor": bg_color, + # "bgcolor": bg_color, "channel": channel, "osdChannel": { - "enable": osd_channel_enabled, "name": osd_channel_name, + "enable": int(osd_channel_enabled), + "name": osd_channel_name, "pos": osd_channel_pos }, - "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos}, - "watermark": osd_watermark_enabled, + "osdTime": {"enable": int(osd_time_enabled), + "pos": osd_time_pos}, + "watermark": int(osd_watermark_enabled), }}}] + # print("SetOsd:", json.dumps(body[0], indent=4)) r_data = self._execute_command('SetOsd', body)[0] if 'value' in r_data and r_data["value"]["rspCode"] == 200: return True diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py index bb62757..5ca39d6 100644 --- a/reolinkapi/mixins/network.py +++ b/reolinkapi/mixins/network.py @@ -25,7 +25,7 @@ def set_net_port(self, http_port: float = 80, https_port: float = 443, media_por "rtspPort": rtsp_port }}}] self._execute_command('SetNetPort', body, multi=True) - print("Successfully Set Network Ports") + # print("Successfully Set Network Ports") return True def set_network_ftp(self, username, password, directory, server_ip, enable) -> Dict: @@ -53,26 +53,98 @@ def set_network_ftp(self, username, password, directory, server_ip, enable) -> D } } } + + For Version 2.0 API: + [{ + "cmd": "SetFtpV20", "param": { + "Ftp": { "anonymous": 0, + "autoDir": 1, + "bpicSingle": 0, "bvideoSingle": 0, + "enable": 1, + "interval": 30, + "maxSize": 100, + "mode": 0, + "onlyFtps": 1, + "password": "***********", + "picCaptureMode": 3, + "picHeight": 1920, + "picInterval": 60, + "picName": "", + "picWidth": 2304, + "port": 21, + "remoteDir": "hello", + "schedule": { + "channel": 0, "table": { + "AI_DOG_CA T": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "AI_PEOPLE": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "AI_VEHICLE": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "MD": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "TIMING": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } }, + "server": "192.168.1.236", + "streamType": 6, + "userName": "ft***er", + "videoName": "sdfs" + } } + }] :return: response """ + """ + body = [{ + "action": 0, + "param": { + "Ftp": { + "password": password, + "remoteDir": directory, + "server": server_ip, + "userName": username, + } + } + }] + """ + + cmd = "SetFtp" + if self.scheduleVersion == 1: + cmd = "SetFtpV20" + body = [ { - "cmd": "SetFtp", - "action": 0, + "cmd": cmd, + # "action": 0, "param": { "Ftp": { "password": password, "remoteDir": directory, "server": server_ip, - "userName": username, - "schedule": { - "enable": enable - } + "userName": username + } + } + } + ] + if self.scheduleVersion == 1: + body[0]["param"]["Ftp"]["enable"] = int(enable) + else: + body[0]["param"]["Ftp"]["schedule"] = { "enable": int(enable) } + + # print(f"Sending {cmd} command: ", body) + return self._execute_command(cmd, body) + + def set_network_ntp(self, enable: bool, server: str, port: int = 123, interval: int = 1440) -> Dict: + body = [ + { + "cmd": "SetNtp", + # "action": 0, + "param": { + "Ntp": { + "enable": int(enable), + "server": server, + "port": port, + "interval": interval } } } ] - return self._execute_command('SetFtp', body) + return self._execute_command('SetNtp', body) def set_wifi(self, ssid: str, password: str) -> Dict: body = [{"cmd": "SetWifi", "action": 0, "param": { @@ -137,9 +209,28 @@ def get_network_email(self) -> Dict: cmd = "GetEmail" if self.scheduleVersion == 1: cmd = "GetEmailV20" - body = [{"cmd": cmd, "action": 0, "param": {}}] + body = [{"cmd": cmd, "action": 1, "param": { "channel": 0 }}] return self._execute_command(cmd, body) + def set_network_email(self, enable: bool) -> Dict: + cmd = "SetEmail" + if self.scheduleVersion == 1: + cmd = "SetEmailV20" + + body = [ { "cmd": cmd, + "param": { + "Email": { + } + } + } + ] + + if self.scheduleVersion == 1: + body[0]["param"]["Email"]["enable"] = int(enable) + else: + body[0]["param"]["Email"]["schedule"] = { "enable": int(enable) } + return self._execute_command('SetEmail', body) + def get_network_ftp(self) -> Dict: """ Get the camera FTP network information diff --git a/reolinkapi/mixins/user.py b/reolinkapi/mixins/user.py index c382c2d..f873fc0 100644 --- a/reolinkapi/mixins/user.py +++ b/reolinkapi/mixins/user.py @@ -1,4 +1,5 @@ from typing import Dict +import json class UserAPIMixin: @@ -32,23 +33,37 @@ 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)[0] - if r_data["value"]["rspCode"] == 200: + + if r_data["code"] == 0 and r_data["value"]["rspCode"] == 200: return True - print("Could not add user. Camera responded with:", r_data["value"]) + + print("Could not add user. Camera responded with:", json.dumps(r_data, indent=4)) return False - def modify_user(self, username: str, password: str) -> bool: + def modify_user(self, username: str, oldpassword: 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}}}] + body = [{"cmd": "ModifyUser", + "action": 0, + "param": {"User": + {"userName": username, + "oldPassword": oldpassword, + "newPassword": password + } + } + } + ] r_data = self._execute_command('ModifyUser', body)[0] - if r_data["value"]["rspCode"] == 200: + # print(f"modify user: {username}\nCamera responded with: {json.dumps(r_data, indent=4)}") + + if r_data["code"] == 0 and r_data["value"]["rspCode"] == 200: return True - print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}") + + print(f"Could not modify user: {username}\nCamera responded with: {json.dumps(r_data, indent=4)}") return False def delete_user(self, username: str) -> bool: From e3c913cf26db9683efee52fc645aae2f9c68c685 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Sat, 14 Oct 2023 11:41:03 -0400 Subject: [PATCH 10/11] Correct call is actually GetMdAlarm GetAlarm is not supported on newer cameras. --- reolinkapi/mixins/alarm.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/reolinkapi/mixins/alarm.py b/reolinkapi/mixins/alarm.py index 53bc6ee..0d04624 100644 --- a/reolinkapi/mixins/alarm.py +++ b/reolinkapi/mixins/alarm.py @@ -4,11 +4,22 @@ class AlarmAPIMixin: """API calls for getting device alarm information.""" + def get_alarm(self) -> Dict: + """ + Gets the device alarm motion + See examples/response/GetAlarmMotion.json for example response data. + :return: response json + """ + cmd = "GetAlarm" + body = [{"cmd": cmd, "action": 1, "param": {"Alarm": {"channel": 0, "type": "md"}}}] + return self._execute_command(cmd, body) + def get_alarm_motion(self) -> Dict: """ 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) + cmd = "GetMdAlarm" + body = [{"cmd": cmd, "action": 1, "param": {"channel": 0}}] + return self._execute_command(cmd, body) From 0e7b321406f1a38bcd09b95b0f4857f09345b084 Mon Sep 17 00:00:00 2001 From: "Paul H. Breslin" Date: Sat, 14 Oct 2023 12:49:14 -0400 Subject: [PATCH 11/11] Fixes to set_recording_encoding Correct call is "SetEnc" not "SetRec". Argument types changed. --- reolinkapi/mixins/record.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/reolinkapi/mixins/record.py b/reolinkapi/mixins/record.py index 232ae99..70d222d 100644 --- a/reolinkapi/mixins/record.py +++ b/reolinkapi/mixins/record.py @@ -25,14 +25,16 @@ def get_recording_advanced(self) -> Dict: body = [{"cmd": cmd, "action": 1, "param": {"channel": 0}}] return self._execute_command(cmd, body) + # NOTE: Changing the camera encoding values apparently makes some (all?) cameras do a full restart. + # Subsequent API calls will fail. Clients must re-connect after the camera resets. def set_recording_encoding(self, - audio: float = 0, - main_bit_rate: float = 8192, - main_frame_rate: float = 8, + audio: bool = True, + main_bit_rate: int = 8192, + main_frame_rate: int = 8, main_profile: str = 'High', - main_size: str = "2560*1440", - sub_bit_rate: float = 160, - sub_frame_rate: float = 7, + main_size: str = "2560*1920", + sub_bit_rate: int = 160, + sub_frame_rate: int = 7, sub_profile: str = 'High', sub_size: str = '640*480') -> Dict: """ @@ -48,16 +50,14 @@ def set_recording_encoding(self, :param sub_size: string Fluent Size :return: response """ - cmd = "SetRec" - if self.scheduleVersion == 1: - cmd = "SetRecV20" + cmd = "SetEnc" body = [ { "cmd": cmd, "action": 0, "param": { "Enc": { - "audio": audio, + "audio": int(audio), "channel": 0, "mainStream": { "bitRate": main_bit_rate,