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 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/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/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/examples/network_config.py b/examples/network_config.py new file mode 100644 index 0000000..51b1956 --- /dev/null +++ b/examples/network_config.py @@ -0,0 +1,62 @@ +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) + +# 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) + +# 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/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..6327dca 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{1,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,194 @@ 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: + # 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) - # 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?!") + return + 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 +512,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 +580,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 +604,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('--main', action='https://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() + config = read_config('camera.cfg') ip = config.get('camera', 'ip') @@ -449,14 +629,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 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) 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 +649,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()) diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 1ded166..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.2.0" +__version__ = "0.4.1" diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index b9efe58..2ef7464 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: """ @@ -120,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/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) 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/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 diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py index f4fe4a6..ae44ba4 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: """ @@ -36,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 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: 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) 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.