From c16189a68b240db9da06532cbf58d5626637f415 Mon Sep 17 00:00:00 2001 From: Hala Khodr Date: Tue, 28 Nov 2023 12:07:44 +0100 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 05/21] 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 06/21] 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 07/21] 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 08/21] 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 09/21] 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 10/21] 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 11/21] 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 12/21] 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 13/21] 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 14/21] 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 15/21] 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 16/21] 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 17/21] 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 18/21] 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 19/21] 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 20/21] 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 21/21] 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: