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/.gitignore b/.gitignore
index a65d046..c0c4d43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+secrets.cfg
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -56,3 +57,6 @@ docs/_build/
# PyBuilder
target/
+
+.idea/
+venv/
\ No newline at end of file
diff --git a/APIHandler.py b/APIHandler.py
deleted file mode 100644
index 2653081..0000000
--- a/APIHandler.py
+++ /dev/null
@@ -1,178 +0,0 @@
-import json
-
-from resthandle import Request
-
-
-class APIHandler:
-
- def __init__(self, ip):
- self.url = "http://" + ip + "/cgi-bin/api.cgi"
- self.token = None
-
- # Token
-
- def login(self, username: str, password: str):
- """
- Get login token
- Must be called first, before any other operation can be performed
- :param username:
- :param password:
- :return:
- """
- try:
- body = [{"cmd": "Login", "action": 0, "param": {"User": {"userName": username, "password": password}}}]
- param = {"cmd": "Login", "token": "null"}
- response = Request.post(self.url, data=body, params=param)
- if response is not None:
- data = json.loads(response.text)[0]
- code = data["code"]
- if int(code) == 0:
- self.token = data["value"]["Token"]["name"]
- print("Login success")
- print(self.token)
- else:
- print("Failed to login\nStatus Code:", response.status_code)
- except Exception as e:
- print("Error Login\n", e)
- raise
-
- ###########
- # NETWORK
- ###########
-
- ###########
- # SET Network
- ###########
- def set_net_port(self, httpPort=80, httpsPort=443, mediaPort=9000, onvifPort=8000, rtmpPort=1935, rtspPort=554):
- """
- Set network ports
- If nothing is specified, the default values will be used
- :param httpPort:
- :param httpsPort:
- :param mediaPort:
- :param onvifPort:
- :param rtmpPort:
- :param rtspPort:
- :return:
- """
- try:
- if self.token is None:
- raise ValueError("Login first")
-
- body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": {
- "httpPort": httpPort,
- "httpsPort": httpsPort,
- "mediaPort": mediaPort,
- "onvifPort": onvifPort,
- "rtmpPort": rtmpPort,
- "rtspPort": rtspPort
- }}}]
- param = {"token": self.token}
- response = Request.post(self.url, data=body, params=param)
- if response is not None:
- if response.status_code == 200:
- print("Successfully Set Network Ports")
- else:
- print("Something went wront\nStatus Code:", response.status_code)
-
- except Exception as e:
- print("Setting Network Port Error\n", e)
- raise
-
- def set_wifi(self, ssid, password):
- try:
- if self.token is None:
- raise ValueError("Login first")
- body = [{"cmd": "SetWifi", "action": 0, "param": {
- "Wifi": {
- "ssid": ssid,
- "password": password
- }}}]
- param = {"cmd": "SetWifi", "token": self.token}
- response = Request.post(self.url, data=body, params=param)
- return json.loads(response.text)
- except Exception as e:
- print("Could not Set Wifi details", e)
- raise
-
- ###########
- # GET
- ###########
- def get_net_ports(self):
- """
- Get network ports
- :return:
- """
- try:
- if self.token is not None:
- raise ValueError("Login first")
-
- body = [{"cmd": "GetNetPort", "action": 1, "param": {}},
- {"cmd": "GetUpnp", "action": 0, "param": {}},
- {"cmd": "GetP2p", "action": 0, "param": {}}]
- param = {"token": self.token}
- response = Request.post(self.url, data=body, params=param)
- return json.loads(response.text)
- except Exception as e:
- print("Get Network Ports", e)
-
- def get_link_local(self):
- """
- Get General network data
- This includes IP address, Device mac, Gateway and DNS
- :return:
- """
- try:
- if self.token is None:
- raise ValueError("Login first")
-
- body = [{"cmd": "GetLocalLink", "action": 1, "param": {}}]
- param = {"cmd": "GetLocalLink", "token": self.token}
- request = Request.post(self.url, data=body, params=param)
- return json.loads(request.text)
- except Exception as e:
- print("Could not get Link Local", e)
- raise
-
- def get_wifi(self):
- try:
- if self.token is None:
- raise ValueError("Login first")
- body = [{"cmd": "GetWifi", "action": 1, "param": {}}]
- param = {"cmd": "GetWifi", "token": self.token}
- response = Request.post(self.url, data=body, params=param)
- return json.loads(response.text)
- except Exception as e:
- print("Could not get Wifi\n", e)
- raise
-
- def scan_wifi(self):
- try:
- if self.token is None:
- raise ValueError("Login first")
- body = [{"cmd": "ScanWifi", "action": 1, "param": {}}]
- param = {"cmd": "ScanWifi", "token": self.token}
- response = Request.post(self.url, data=body, params=param)
- return json.loads(response.text)
- except Exception as e:
- print("Could not Scan wifi\n", e)
- raise
-
- ###########
- # SYSTEM
- ###########
-
- ###########
- # GET
- ###########
- def get_general_system(self):
- try:
- if self.token is None:
- raise ValueError("Login first")
- body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}]
- param = {"token": self.token}
- response = Request.post(self.url, data=body, params=param)
- return json.loads(response.text)
- except Exception as e:
- print("Could not get General System settings\n", e)
- raise
diff --git a/Camera.py b/Camera.py
deleted file mode 100644
index 1e82361..0000000
--- a/Camera.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from APIHandler import APIHandler
-
-
-class Camera(APIHandler):
-
- def __init__(self, ip, username="admin", password=""):
- APIHandler.__init__(self, ip)
- self.ip = ip
- self.username = username
- self.password = password
- super().login(self.username, self.password)
diff --git a/ConfigHandler.py b/ConfigHandler.py
deleted file mode 100644
index 67e8d62..0000000
--- a/ConfigHandler.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import io
-
-import yaml
-
-
-class ConfigHandler:
- camera_settings = {}
-
- @staticmethod
- def load() -> yaml or None:
- try:
- stream = io.open("config.yml", 'r', encoding='utf8')
- data = yaml.safe_load(stream)
- return data
- except Exception as e:
- print("Config Property Error\n", e)
- return None
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..defbd59
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,16 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+
+[packages]
+pillow = "*"
+pyyaml = "*"
+requests = "*"
+numpy = "*"
+opencv-python = "*"
+pysocks = "*"
+
+[requires]
diff --git a/README.md b/README.md
index a9eff6e..87a205f 100644
--- a/README.md
+++ b/README.md
@@ -1,57 +1,123 @@
-## ReolinkCameraAPI
+
Reolink Python Api Client
-### Purpose
+
+
+
+
+
+
+
-This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS )
+---
+A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Cameras,
+although they have a basic API document - it does not satisfy the need for extensive camera communication.
-### But Reolink gives an API in their documentation
+Check out our documentation for more information on how to use the software at [https://reolink.oleaintueri.com](https://reolink.oleaintueri.com)
-Not really. They only deliver a really basic API to retrieve Image data and Video data.
-### How?
+Other Supported Languages:
+ - Go: [reolinkapigo](https://github.com/ReolinkCameraAPI/reolinkapigo)
-You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network.
+### Join us on Discord
+
+ https://discord.gg/8z3fdAmZJP
+
+
+### Sponsorship
+
+
+
+[Oleaintueri](https://oleaintueri.com) is sponsoring the development and maintenance of these projects within their organisation.
+
+
+---
### Get started
Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands.
+See the `examples` directory.
+
+### Using the library as a Python Module
+
+Install the package via PyPi
+
+ pip install reolinkapi
+
+Install from GitHub
+
+ pip install git+https://github.com/ReolinkCameraAPI/reolinkapipy.git
+
+If you want to include the video streaming functionality you need to include the streaming "extra" dependencies
+
+ pip install 'reolinkapi[streaming]'
+
+## Contributors
+
+---
+
+### Styling and Standards
+
+This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/)
+
+### How can I become a contributor?
+
+#### Step 1
+
+Get the Restful API calls by looking through the HTTP Requests made in the camera's web UI. I use Google Chrome developer mode (ctr + shift + i) -> Network.
+
+#### Step 2
+
+- Fork the repository
+- pip install -r requirements.txt
+- Make your changes
+
+#### Step 3
+
+Make a pull request.
+
### API Requests Implementation Plan:
+Stream:
+- [X] Blocking RTSP stream
+- [X] Non-Blocking RTSP stream
+
GET:
- [X] Login
-- [ ] Display -> OSD
-- [ ] Recording -> Encode (Clear and Fluent Stream)
-- [ ] Recording -> Advance (Scheduling)
+- [X] Logout
+- [X] Display -> OSD
+- [X] Recording -> Encode (Clear and Fluent Stream)
+- [X] Recording -> Advance (Scheduling)
- [X] Network -> General
- [X] Network -> Advanced
-- [ ] Network -> DDNS
-- [ ] Network -> NTP
-- [ ] Network -> E-mail
-- [ ] Network -> FTP
-- [ ] Network -> Push
+- [X] Network -> DDNS
+- [X] Network -> NTP
+- [X] Network -> E-mail
+- [X] Network -> FTP
+- [X] Network -> Push
- [X] Network -> WIFI
-- [ ] Alarm -> Motion
+- [X] Alarm -> Motion
- [X] System -> General
-- [ ] System -> DST
-- [ ] System -> Information
+- [X] System -> DST
+- [X] System -> Information
- [ ] System -> Maintenance
-- [ ] System -> Performance
+- [X] System -> Performance
- [ ] System -> Reboot
-- [ ] User -> Online User
-- [ ] User -> Add User
-- [ ] User -> Manage User
-- [ ] Device -> HDD/SD Card
-- [ ] Zoom
-- [ ] Focus
-- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate)
+- [X] User -> Online User
+- [X] User -> Add User
+- [X] User -> Manage User
+- [X] Device -> HDD/SD Card
+- [x] PTZ -> Presets, Calibration Status
+- [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)
-- [ ] Image Data
+- [X] Image Data -> "Snap" Frame from Video Stream
SET:
-- [ ] Display -> OSD
-- [ ] Recording -> Encode (Clear and Fluent Stream)
+- [X] Display -> OSD
+- [X] Recording -> Encode (Clear and Fluent Stream)
- [ ] Recording -> Advance (Scheduling)
- [X] Network -> General
- [X] Network -> Advanced
@@ -62,14 +128,32 @@ SET:
- [ ] Network -> Push
- [X] Network -> WIFI
- [ ] Alarm -> Motion
-- [X] System -> General
+- [ ] System -> General
- [ ] System -> DST
-- [ ] System -> Reboot
-- [ ] User -> Online User
-- [ ] User -> Add User
-- [ ] User -> Manage User
-- [ ] Device -> HDD/SD Card
-- [ ] Zoom
-- [ ] Focus
-- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate)
-- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR)
+- [X] System -> Reboot
+- [X] User -> Online User
+- [X] User -> Add User
+- [X] User -> Manage User
+- [X] Device -> HDD/SD Card (Format)
+- [x] PTZ (including calibrate)
+- [x] Zoom
+- [x] Focus
+- [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate)
+- [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR)
+
+### Supported Cameras
+
+Any Reolink camera that has a web UI should work. The other's requiring special Reolink clients
+do not work and is not supported here.
+
+- RLC-411WS
+- RLC-423
+- RLC-420-5MP
+- RLC-410-5MP
+- RLC-510A
+- RLC-520
+- RLC-823A
+- C1-Pro
+- D400
+- E1 Zoom
+
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
new file mode 100644
index 0000000..1fa5bdd
--- /dev/null
+++ b/examples/basic_usage.py
@@ -0,0 +1,12 @@
+import reolinkapi
+
+if __name__ == "__main__":
+ cam = reolinkapi.Camera("192.168.0.102", defer_login=True)
+
+ # must first login since I defer have deferred the login process
+ cam.login()
+
+ dst = cam.get_dst()
+ ok = cam.add_user("foo", "bar", "admin")
+ alarm = cam.get_alarm_motion()
+ 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/download_motions.py b/examples/download_motions.py
new file mode 100644
index 0000000..809571f
--- /dev/null
+++ b/examples/download_motions.py
@@ -0,0 +1,54 @@
+"""Downloads all motion events from camera from the past hour."""
+import os
+from configparser import RawConfigParser
+from datetime import datetime as dt, timedelta
+from reolinkapi import Camera
+
+
+def read_config(props_path: str) -> dict:
+ """Reads in a properties file into variables.
+
+ NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+ # secrets.cfg
+ [camera]
+ ip={ip_address}
+ username={username}
+ password={password}
+ """
+ config = RawConfigParser()
+ assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+ config.read(props_path)
+ return config
+
+
+# Read in your ip, username, & password
+# (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure)
+config = read_config('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)
+
+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='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=1)
+
+
+output_files = []
+for i, motion in enumerate(processed_motions):
+ fname = motion['filename']
+ # Download the 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/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))
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/response/GetAlarmMotion.json b/examples/response/GetAlarmMotion.json
new file mode 100644
index 0000000..56be0cc
--- /dev/null
+++ b/examples/response/GetAlarmMotion.json
@@ -0,0 +1,150 @@
+[
+ {
+ "cmd": "GetAlarm",
+ "code": 0,
+ "initial": {
+ "Alarm": {
+ "action": { "mail": 1, "push": 1, "recChannel": [0] },
+ "channel": 0,
+ "enable": 1,
+ "schedule": {
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ },
+ "scope": {
+ "cols": 80,
+ "rows": 45,
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ },
+ "sens": [
+ {
+ "beginHour": 0,
+ "beginMin": 0,
+ "endHour": 6,
+ "endMin": 0,
+ "sensitivity": 10
+ },
+ {
+ "beginHour": 6,
+ "beginMin": 0,
+ "endHour": 12,
+ "endMin": 0,
+ "sensitivity": 10
+ },
+ {
+ "beginHour": 12,
+ "beginMin": 0,
+ "endHour": 18,
+ "endMin": 0,
+ "sensitivity": 10
+ },
+ {
+ "beginHour": 18,
+ "beginMin": 0,
+ "endHour": 23,
+ "endMin": 59,
+ "sensitivity": 10
+ }
+ ],
+ "type": "md"
+ }
+ },
+ "range": {
+ "Alarm": {
+ "action": { "mail": "boolean", "push": "boolean", "recChannel": [0] },
+ "channel": 0,
+ "enable": "boolean",
+ "schedule": { "table": { "maxLen": 168, "minLen": 168 } },
+ "scope": {
+ "cols": { "max": 80, "min": 80 },
+ "rows": { "max": 45, "min": 45 },
+ "table": { "maxLen": 8159 }
+ },
+ "sens": [
+ {
+ "beginHour": { "max": 23, "min": 0 },
+ "beginMin": { "max": 59, "min": 0 },
+ "endHour": { "max": 23, "min": 0 },
+ "endMin": { "max": 59, "min": 0 },
+ "id": 0,
+ "sensitivity": { "max": 50, "min": 1 }
+ },
+ {
+ "beginHour": { "max": 23, "min": 0 },
+ "beginMin": { "max": 59, "min": 0 },
+ "endHour": { "max": 23, "min": 0 },
+ "endMin": { "max": 59, "min": 0 },
+ "id": 1,
+ "sensitivity": { "max": 50, "min": 1 }
+ },
+ {
+ "beginHour": { "max": 23, "min": 0 },
+ "beginMin": { "max": 59, "min": 0 },
+ "endHour": { "max": 23, "min": 0 },
+ "endMin": { "max": 59, "min": 0 },
+ "id": 2,
+ "sensitivity": { "max": 50, "min": 1 }
+ },
+ {
+ "beginHour": { "max": 23, "min": 0 },
+ "beginMin": { "max": 59, "min": 0 },
+ "endHour": { "max": 23, "min": 0 },
+ "endMin": { "max": 59, "min": 0 },
+ "id": 3,
+ "sensitivity": { "max": 50, "min": 1 }
+ }
+ ],
+ "type": "md"
+ }
+ },
+ "value": {
+ "Alarm": {
+ "action": { "mail": 1, "push": 1, "recChannel": [0] },
+ "channel": 0,
+ "enable": 1,
+ "schedule": {
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ },
+ "scope": {
+ "cols": 80,
+ "rows": 45,
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ },
+ "sens": [
+ {
+ "beginHour": 0,
+ "beginMin": 0,
+ "endHour": 6,
+ "endMin": 0,
+ "id": 0,
+ "sensitivity": 10
+ },
+ {
+ "beginHour": 6,
+ "beginMin": 0,
+ "endHour": 12,
+ "endMin": 0,
+ "id": 1,
+ "sensitivity": 10
+ },
+ {
+ "beginHour": 12,
+ "beginMin": 0,
+ "endHour": 18,
+ "endMin": 0,
+ "id": 2,
+ "sensitivity": 10
+ },
+ {
+ "beginHour": 18,
+ "beginMin": 0,
+ "endHour": 23,
+ "endMin": 59,
+ "id": 3,
+ "sensitivity": 10
+ }
+ ],
+ "type": "md"
+ }
+ }
+ }
+]
diff --git a/examples/response/GetDSTInfo.json b/examples/response/GetDSTInfo.json
new file mode 100644
index 0000000..5895d9e
--- /dev/null
+++ b/examples/response/GetDSTInfo.json
@@ -0,0 +1,35 @@
+[
+ {
+ "cmd": "GetTime",
+ "code": 0,
+ "value": {
+ "Dst": {
+ "enable": 1,
+ "endHour": 2,
+ "endMin": 0,
+ "endMon": 11,
+ "endSec": 0,
+ "endWeek": 1,
+ "endWeekday": 0,
+ "offset": 1,
+ "startHour": 2,
+ "startMin": 0,
+ "startMon": 3,
+ "startSec": 0,
+ "startWeek": 1,
+ "startWeekday": 0
+ },
+ "Time": {
+ "day": 27,
+ "hour": 18,
+ "hourFmt": 0,
+ "min": 50,
+ "mon": 10,
+ "sec": 46,
+ "timeFmt": "MM/DD/YYYY",
+ "timeZone": 21600,
+ "year": 2020
+ }
+ }
+ }
+]
diff --git a/examples/response/GetDevInfo.json b/examples/response/GetDevInfo.json
new file mode 100644
index 0000000..9018183
--- /dev/null
+++ b/examples/response/GetDevInfo.json
@@ -0,0 +1,26 @@
+[
+ {
+ "cmd": "GetDevInfo",
+ "code": 0,
+ "value": {
+ "DevInfo": {
+ "B485": 0,
+ "IOInputNum": 0,
+ "IOOutputNum": 0,
+ "audioNum": 0,
+ "buildDay": "build 18081408",
+ "cfgVer": "v2.0.0.0",
+ "channelNum": 1,
+ "detail": "IPC_3816M100000000100000",
+ "diskNum": 1,
+ "firmVer": "v2.0.0.1389_18081408",
+ "hardVer": "IPC_3816M",
+ "model": "RLC-411WS",
+ "name": "Camera1_withpersonality",
+ "serial": "00000000000000",
+ "type": "IPC",
+ "wifi": 1
+ }
+ }
+ }
+]
diff --git a/examples/response/GetEnc.json b/examples/response/GetEnc.json
new file mode 100644
index 0000000..547ed6f
--- /dev/null
+++ b/examples/response/GetEnc.json
@@ -0,0 +1,377 @@
+[
+ {
+ "cmd": "GetEnc",
+ "code": 0,
+ "initial": {
+ "Enc": {
+ "audio": 0,
+ "channel": 0,
+ "mainStream": {
+ "bitRate": 4096,
+ "frameRate": 15,
+ "profile": "High",
+ "size": "3072*1728"
+ },
+ "subStream": {
+ "bitRate": 160,
+ "frameRate": 7,
+ "profile": "High",
+ "size": "640*360"
+ }
+ }
+ },
+ "range": {
+ "Enc": [
+ {
+ "audio": "boolean",
+ "mainStream": {
+ "bitRate": [
+ 1024,
+ 1536,
+ 2048,
+ 3072,
+ 4096,
+ 5120,
+ 6144,
+ 7168,
+ 8192
+ ],
+ "default": {
+ "bitRate": 4096,
+ "frameRate": 15
+ },
+ "frameRate": [
+ 20,
+ 18,
+ 16,
+ 15,
+ 12,
+ 10,
+ 8,
+ 6,
+ 4,
+ 2
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "3072*1728"
+ },
+ "subStream": {
+ "bitRate": [
+ 64,
+ 128,
+ 160,
+ 192,
+ 256,
+ 384,
+ 512
+ ],
+ "default": {
+ "bitRate": 160,
+ "frameRate": 7
+ },
+ "frameRate": [
+ 15,
+ 10,
+ 7,
+ 4
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "640*360"
+ }
+ },
+ {
+ "audio": "boolean",
+ "mainStream": {
+ "bitRate": [
+ 1024,
+ 1536,
+ 2048,
+ 3072,
+ 4096,
+ 5120,
+ 6144,
+ 7168,
+ 8192
+ ],
+ "default": {
+ "bitRate": 4096,
+ "frameRate": 15
+ },
+ "frameRate": [
+ 20,
+ 18,
+ 16,
+ 15,
+ 12,
+ 10,
+ 8,
+ 6,
+ 4,
+ 2
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "2592*1944"
+ },
+ "subStream": {
+ "bitRate": [
+ 64,
+ 128,
+ 160,
+ 192,
+ 256,
+ 384,
+ 512
+ ],
+ "default": {
+ "bitRate": 160,
+ "frameRate": 7
+ },
+ "frameRate": [
+ 15,
+ 10,
+ 7,
+ 4
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "640*360"
+ }
+ },
+ {
+ "audio": "boolean",
+ "mainStream": {
+ "bitRate": [
+ 1024,
+ 1536,
+ 2048,
+ 3072,
+ 4096,
+ 5120,
+ 6144,
+ 7168,
+ 8192
+ ],
+ "default": {
+ "bitRate": 3072,
+ "frameRate": 15
+ },
+ "frameRate": [
+ 30,
+ 22,
+ 20,
+ 18,
+ 16,
+ 15,
+ 12,
+ 10,
+ 8,
+ 6,
+ 4,
+ 2
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "2560*1440"
+ },
+ "subStream": {
+ "bitRate": [
+ 64,
+ 128,
+ 160,
+ 192,
+ 256,
+ 384,
+ 512
+ ],
+ "default": {
+ "bitRate": 160,
+ "frameRate": 7
+ },
+ "frameRate": [
+ 15,
+ 10,
+ 7,
+ 4
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "640*360"
+ }
+ },
+ {
+ "audio": "boolean",
+ "mainStream": {
+ "bitRate": [
+ 1024,
+ 1536,
+ 2048,
+ 3072,
+ 4096,
+ 5120,
+ 6144,
+ 7168,
+ 8192
+ ],
+ "default": {
+ "bitRate": 3072,
+ "frameRate": 15
+ },
+ "frameRate": [
+ 30,
+ 22,
+ 20,
+ 18,
+ 16,
+ 15,
+ 12,
+ 10,
+ 8,
+ 6,
+ 4,
+ 2
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "2048*1536"
+ },
+ "subStream": {
+ "bitRate": [
+ 64,
+ 128,
+ 160,
+ 192,
+ 256,
+ 384,
+ 512
+ ],
+ "default": {
+ "bitRate": 160,
+ "frameRate": 7
+ },
+ "frameRate": [
+ 15,
+ 10,
+ 7,
+ 4
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "640*360"
+ }
+ },
+ {
+ "audio": "boolean",
+ "mainStream": {
+ "bitRate": [
+ 1024,
+ 1536,
+ 2048,
+ 3072,
+ 4096,
+ 5120,
+ 6144,
+ 7168,
+ 8192
+ ],
+ "default": {
+ "bitRate": 3072,
+ "frameRate": 15
+ },
+ "frameRate": [
+ 30,
+ 22,
+ 20,
+ 18,
+ 16,
+ 15,
+ 12,
+ 10,
+ 8,
+ 6,
+ 4,
+ 2
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "2304*1296"
+ },
+ "subStream": {
+ "bitRate": [
+ 64,
+ 128,
+ 160,
+ 192,
+ 256,
+ 384,
+ 512
+ ],
+ "default": {
+ "bitRate": 160,
+ "frameRate": 7
+ },
+ "frameRate": [
+ 15,
+ 10,
+ 7,
+ 4
+ ],
+ "profile": [
+ "Base",
+ "Main",
+ "High"
+ ],
+ "size": "640*360"
+ }
+ }
+ ]
+ },
+ "value": {
+ "Enc": {
+ "audio": 0,
+ "channel": 0,
+ "mainStream": {
+ "bitRate": 2048,
+ "frameRate": 20,
+ "profile": "Main",
+ "size": "3072*1728"
+ },
+ "subStream": {
+ "bitRate": 64,
+ "frameRate": 4,
+ "profile": "High",
+ "size": "640*360"
+ }
+ }
+ }
+ }
+]
diff --git a/examples/response/GetGeneralSystem.json b/examples/response/GetGeneralSystem.json
new file mode 100644
index 0000000..814671e
--- /dev/null
+++ b/examples/response/GetGeneralSystem.json
@@ -0,0 +1,100 @@
+[
+ {
+ "cmd": "GetTime",
+ "code": 0,
+ "initial": {
+ "Dst": {
+ "enable": 0,
+ "endHour": 2,
+ "endMin": 0,
+ "endMon": 10,
+ "endSec": 0,
+ "endWeek": 5,
+ "endWeekday": 0,
+ "offset": 1,
+ "startHour": 2,
+ "startMin": 0,
+ "startMon": 3,
+ "startSec": 0,
+ "startWeek": 2,
+ "startWeekday": 0
+ },
+ "Time": {
+ "day": 1,
+ "hour": 0,
+ "hourFmt": 0,
+ "min": 0,
+ "mon": 0,
+ "sec": 0,
+ "timeFmt": "DD/MM/YYYY",
+ "timeZone": 28800,
+ "year": 0
+ }
+ },
+ "range": {
+ "Dst": {
+ "enable": "boolean",
+ "endHour": { "max": 23, "min": 0 },
+ "endMin": { "max": 59, "min": 0 },
+ "endMon": { "max": 12, "min": 1 },
+ "endSec": { "max": 59, "min": 0 },
+ "endWeek": { "max": 5, "min": 1 },
+ "endWeekday": { "max": 6, "min": 0 },
+ "offset": { "max": 2, "min": 1 },
+ "startHour": { "max": 23, "min": 0 },
+ "startMin": { "max": 59, "min": 0 },
+ "startMon": { "max": 12, "min": 1 },
+ "startSec": { "max": 59, "min": 0 },
+ "startWeek": { "max": 5, "min": 1 },
+ "startWeekday": { "max": 6, "min": 0 }
+ },
+ "Time": {
+ "day": { "max": 31, "min": 1 },
+ "hour": { "max": 23, "min": 0 },
+ "hourFmt": { "max": 1, "min": 0 },
+ "min": { "max": 59, "min": 0 },
+ "mon": { "max": 12, "min": 1 },
+ "sec": { "max": 59, "min": 0 },
+ "timeFmt": ["MM/DD/YYYY", "YYYY/MM/DD", "DD/MM/YYYY"],
+ "timeZone": { "max": 43200, "min": -46800 },
+ "year": { "max": 2100, "min": 1900 }
+ }
+ },
+ "value": {
+ "Dst": {
+ "enable": 1,
+ "endHour": 2,
+ "endMin": 0,
+ "endMon": 11,
+ "endSec": 0,
+ "endWeek": 1,
+ "endWeekday": 0,
+ "offset": 1,
+ "startHour": 2,
+ "startMin": 0,
+ "startMon": 3,
+ "startSec": 0,
+ "startWeek": 1,
+ "startWeekday": 0
+ },
+ "Time": {
+ "day": 9,
+ "hour": 15,
+ "hourFmt": 0,
+ "min": 33,
+ "mon": 12,
+ "sec": 58,
+ "timeFmt": "MM/DD/YYYY",
+ "timeZone": 21600,
+ "year": 2020
+ }
+ }
+ },
+ {
+ "cmd": "GetNorm",
+ "code": 0,
+ "initial": { "norm": "NTSC" },
+ "range": { "norm": ["PAL", "NTSC"] },
+ "value": { "norm": "NTSC" }
+ }
+]
diff --git a/examples/response/GetHddInfo.json b/examples/response/GetHddInfo.json
new file mode 100644
index 0000000..d95b7aa
--- /dev/null
+++ b/examples/response/GetHddInfo.json
@@ -0,0 +1,17 @@
+[
+ {
+ "cmd": "GetHddInfo",
+ "code": 0,
+ "value": {
+ "HddInfo": [
+ {
+ "capacity": 15181,
+ "format": 1,
+ "id": 0,
+ "mount": 1,
+ "size": 15181
+ }
+ ]
+ }
+ }
+]
diff --git a/examples/response/GetMask.json b/examples/response/GetMask.json
new file mode 100644
index 0000000..beb345a
--- /dev/null
+++ b/examples/response/GetMask.json
@@ -0,0 +1,40 @@
+[
+ {
+ "cmd": "GetMask",
+ "code": 0,
+ "initial": {
+ "Mask": {
+ "area": [
+ {
+ "block": {
+ "height": 0,
+ "width": 0,
+ "x": 0,
+ "y": 0
+ },
+ "screen": {
+ "height": 0,
+ "width": 0
+ }
+ }
+ ],
+ "channel": 0,
+ "enable": 0
+ }
+ },
+ "range": {
+ "Mask": {
+ "channel": 0,
+ "enable": "boolean",
+ "maxAreas": 4
+ }
+ },
+ "value": {
+ "Mask": {
+ "area": null,
+ "channel": 0,
+ "enable": 0
+ }
+ }
+ }
+]
diff --git a/examples/response/GetNetworkAdvanced.json b/examples/response/GetNetworkAdvanced.json
new file mode 100644
index 0000000..25c729e
--- /dev/null
+++ b/examples/response/GetNetworkAdvanced.json
@@ -0,0 +1,42 @@
+[
+ {
+ "cmd": "GetNetPort",
+ "code": 0,
+ "initial": {
+ "NetPort": {
+ "httpPort": 80,
+ "httpsPort": 443,
+ "mediaPort": 9000,
+ "onvifPort": 8000,
+ "rtmpPort": 1935,
+ "rtspPort": 554
+ }
+ },
+ "range": {
+ "NetPort": {
+ "httpPort": { "max": 65535, "min": 1 },
+ "httpsPort": { "max": 65535, "min": 1 },
+ "mediaPort": { "max": 65535, "min": 1 },
+ "onvifPort": { "max": 65535, "min": 1 },
+ "rtmpPort": { "max": 65535, "min": 1 },
+ "rtspPort": { "max": 65535, "min": 1 }
+ }
+ },
+ "value": {
+ "NetPort": {
+ "httpPort": 80,
+ "httpsPort": 443,
+ "mediaPort": 9000,
+ "onvifPort": 8000,
+ "rtmpPort": 1935,
+ "rtspPort": 554
+ }
+ }
+ },
+ { "cmd": "GetUpnp", "code": 0, "value": { "Upnp": { "enable": 0 } } },
+ {
+ "cmd": "GetP2p",
+ "code": 0,
+ "value": { "P2p": { "enable": 0, "uid": "99999999999999" } }
+ }
+]
diff --git a/examples/response/GetNetworkDDNS.json b/examples/response/GetNetworkDDNS.json
new file mode 100644
index 0000000..a79127a
--- /dev/null
+++ b/examples/response/GetNetworkDDNS.json
@@ -0,0 +1,15 @@
+[
+ {
+ "cmd": "GetDdns",
+ "code": 0,
+ "value": {
+ "Ddns": {
+ "domain": "",
+ "enable": 0,
+ "password": "",
+ "type": "no-ip",
+ "userName": ""
+ }
+ }
+ }
+]
diff --git a/examples/response/GetNetworkEmail.json b/examples/response/GetNetworkEmail.json
new file mode 100644
index 0000000..9595a2a
--- /dev/null
+++ b/examples/response/GetNetworkEmail.json
@@ -0,0 +1,25 @@
+[
+ {
+ "cmd": "GetEmail",
+ "code": 0,
+ "value": {
+ "Email": {
+ "addr1": "",
+ "addr2": "",
+ "addr3": "",
+ "attachment": "picture",
+ "interval": "5 Minutes",
+ "nickName": "",
+ "password": "",
+ "schedule": {
+ "enable": 1,
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ },
+ "smtpPort": 465,
+ "smtpServer": "smtp.gmail.com",
+ "ssl": 1,
+ "userName": ""
+ }
+ }
+ }
+]
diff --git a/examples/response/GetNetworkFtp.json b/examples/response/GetNetworkFtp.json
new file mode 100644
index 0000000..8ba4622
--- /dev/null
+++ b/examples/response/GetNetworkFtp.json
@@ -0,0 +1,24 @@
+[
+ {
+ "cmd": "GetFtp",
+ "code": 0,
+ "value": {
+ "Ftp": {
+ "anonymous": 0,
+ "interval": 30,
+ "maxSize": 100,
+ "mode": 0,
+ "password": "",
+ "port": 21,
+ "remoteDir": "",
+ "schedule": {
+ "enable": 1,
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ },
+ "server": "",
+ "streamType": 0,
+ "userName": ""
+ }
+ }
+ }
+]
diff --git a/examples/response/GetNetworkGeneral.json b/examples/response/GetNetworkGeneral.json
new file mode 100644
index 0000000..958bbdd
--- /dev/null
+++ b/examples/response/GetNetworkGeneral.json
@@ -0,0 +1,19 @@
+[
+ {
+ "cmd": "GetLocalLink",
+ "code": 0,
+ "value": {
+ "LocalLink": {
+ "activeLink": "LAN",
+ "dns": { "auto": 1, "dns1": "192.168.255.4", "dns2": "192.168.255.4" },
+ "mac": "EC:71:DB:AA:59:CF",
+ "static": {
+ "gateway": "192.168.255.1",
+ "ip": "192.168.255.58",
+ "mask": "255.255.255.0"
+ },
+ "type": "DHCP"
+ }
+ }
+ }
+]
diff --git a/examples/response/GetNetworkNTP.json b/examples/response/GetNetworkNTP.json
new file mode 100644
index 0000000..7293fed
--- /dev/null
+++ b/examples/response/GetNetworkNTP.json
@@ -0,0 +1,14 @@
+[
+ {
+ "cmd": "GetNtp",
+ "code": 0,
+ "value": {
+ "Ntp": {
+ "enable": 1,
+ "interval": 1440,
+ "port": 123,
+ "server": "ntp.moos.xyz"
+ }
+ }
+ }
+]
diff --git a/examples/response/GetNetworkPush.json b/examples/response/GetNetworkPush.json
new file mode 100644
index 0000000..399207f
--- /dev/null
+++ b/examples/response/GetNetworkPush.json
@@ -0,0 +1,14 @@
+[
+ {
+ "cmd": "GetPush",
+ "code": 0,
+ "value": {
+ "Push": {
+ "schedule": {
+ "enable": 1,
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ }
+ }
+ }
+ }
+]
diff --git a/examples/response/GetOnline.json b/examples/response/GetOnline.json
new file mode 100644
index 0000000..34f59d1
--- /dev/null
+++ b/examples/response/GetOnline.json
@@ -0,0 +1,17 @@
+[
+ {
+ "cmd": "GetOnline",
+ "code": 0,
+ "value": {
+ "User": [
+ {
+ "canbeDisconn": 0,
+ "ip": "192.168.1.100",
+ "level": "admin",
+ "sessionId": 1000,
+ "userName": "admin"
+ }
+ ]
+ }
+ }
+]
diff --git a/examples/response/GetOsd.json b/examples/response/GetOsd.json
new file mode 100644
index 0000000..e925b90
--- /dev/null
+++ b/examples/response/GetOsd.json
@@ -0,0 +1,67 @@
+[
+ {
+ "cmd": "GetOsd",
+ "code": 0,
+ "initial": {
+ "Osd": {
+ "bgcolor": 0,
+ "channel": 0,
+ "osdChannel": {
+ "enable": 1,
+ "name": "Camera1",
+ "pos": "Lower Right"
+ },
+ "osdTime": {
+ "enable": 1,
+ "pos": "Top Center"
+ }
+ }
+ },
+ "range": {
+ "Osd": {
+ "bgcolor": "boolean",
+ "channel": 0,
+ "osdChannel": {
+ "enable": "boolean",
+ "name": {
+ "maxLen": 31
+ },
+ "pos": [
+ "Upper Left",
+ "Top Center",
+ "Upper Right",
+ "Lower Left",
+ "Bottom Center",
+ "Lower Right"
+ ]
+ },
+ "osdTime": {
+ "enable": "boolean",
+ "pos": [
+ "Upper Left",
+ "Top Center",
+ "Upper Right",
+ "Lower Left",
+ "Bottom Center",
+ "Lower Right"
+ ]
+ }
+ }
+ },
+ "value": {
+ "Osd": {
+ "bgcolor": 0,
+ "channel": 0,
+ "osdChannel": {
+ "enable": 0,
+ "name": "FarRight",
+ "pos": "Lower Right"
+ },
+ "osdTime": {
+ "enable": 0,
+ "pos": "Top Center"
+ }
+ }
+ }
+ }
+]
\ No newline at end of file
diff --git a/examples/response/GetPerformance.json b/examples/response/GetPerformance.json
new file mode 100644
index 0000000..73bc9ba
--- /dev/null
+++ b/examples/response/GetPerformance.json
@@ -0,0 +1,13 @@
+[
+ {
+ "cmd": "GetPerformance",
+ "code": 0,
+ "value": {
+ "Performance": {
+ "codecRate": 2154,
+ "cpuUsed": 14,
+ "netThroughput": 0
+ }
+ }
+ }
+]
diff --git a/examples/response/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/GetRec.json b/examples/response/GetRec.json
new file mode 100644
index 0000000..623c353
--- /dev/null
+++ b/examples/response/GetRec.json
@@ -0,0 +1,45 @@
+[
+ {
+ "cmd": "GetRec",
+ "code": 0,
+ "initial": {
+ "Rec": {
+ "channel": 0,
+ "overwrite": 1,
+ "postRec": "15 Seconds",
+ "preRec": 1,
+ "schedule": {
+ "enable": 1,
+ "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"
+ }
+ }
+ },
+ "range": {
+ "Rec": {
+ "channel": 0,
+ "overwrite": "boolean",
+ "postRec": [
+ "15 Seconds",
+ "30 Seconds",
+ "1 Minute"
+ ],
+ "preRec": "boolean",
+ "schedule": {
+ "enable": "boolean"
+ }
+ }
+ },
+ "value": {
+ "Rec": {
+ "channel": 0,
+ "overwrite": 1,
+ "postRec": "15 Seconds",
+ "preRec": 1,
+ "schedule": {
+ "enable": 1,
+ "table": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+ }
+ }
+ }
+ }
+]
diff --git a/examples/response/GetUser.json b/examples/response/GetUser.json
new file mode 100644
index 0000000..d3d77e7
--- /dev/null
+++ b/examples/response/GetUser.json
@@ -0,0 +1,35 @@
+[
+ {
+ "cmd": "GetUser",
+ "code": 0,
+ "initial": {
+ "User": {
+ "level": "guest"
+ }
+ },
+ "range": {
+ "User": {
+ "level": [
+ "guest",
+ "admin"
+ ],
+ "password": {
+ "maxLen": 31,
+ "minLen": 6
+ },
+ "userName": {
+ "maxLen": 31,
+ "minLen": 1
+ }
+ }
+ },
+ "value": {
+ "User": [
+ {
+ "level": "admin",
+ "userName": "admin"
+ }
+ ]
+ }
+ }
+]
diff --git a/examples/response/Login.json b/examples/response/Login.json
new file mode 100644
index 0000000..5ce4120
--- /dev/null
+++ b/examples/response/Login.json
@@ -0,0 +1,7 @@
+[
+ {
+ "cmd": "Login",
+ "code": 0,
+ "value": { "Token": { "leaseTime": 3600, "name": "xxxxxxxxxxxx" } }
+ }
+]
diff --git a/examples/response/Logout.json b/examples/response/Logout.json
new file mode 100644
index 0000000..a0588b2
--- /dev/null
+++ b/examples/response/Logout.json
@@ -0,0 +1,7 @@
+[
+ {
+ "cmd": "Logout",
+ "code": 0,
+ "value": { "rspCode": 200 }
+ }
+]
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/examples/response/PtzCtrl.json b/examples/response/PtzCtrl.json
new file mode 100644
index 0000000..fa8e1fb
--- /dev/null
+++ b/examples/response/PtzCtrl.json
@@ -0,0 +1,9 @@
+[
+ {
+ "cmd" : "PtzCtrl",
+ "code" : 0,
+ "value" : {
+ "rspCode" : 200
+ }
+ }
+ ]
\ No newline at end of file
diff --git a/examples/response/Reboot.json b/examples/response/Reboot.json
new file mode 100644
index 0000000..eb10195
--- /dev/null
+++ b/examples/response/Reboot.json
@@ -0,0 +1,9 @@
+[
+ {
+ "cmd": "Reboot",
+ "code": 0,
+ "value": {
+ "rspCode": 200
+ }
+ }
+]
diff --git a/examples/response/SetAdvImageSettings.json b/examples/response/SetAdvImageSettings.json
new file mode 100644
index 0000000..780adf3
--- /dev/null
+++ b/examples/response/SetAdvImageSettings.json
@@ -0,0 +1,10 @@
+[
+ {
+ "cmd" : "SetIsp",
+ "code" : 0,
+ "value" : {
+ "rspCode" : 200
+ }
+ }
+ ]
+
\ No newline at end of file
diff --git a/examples/response/SetImageSettings.json b/examples/response/SetImageSettings.json
new file mode 100644
index 0000000..26ef93d
--- /dev/null
+++ b/examples/response/SetImageSettings.json
@@ -0,0 +1,10 @@
+[
+ {
+ "cmd" : "SetImage",
+ "code" : 0,
+ "value" : {
+ "rspCode" : 200
+ }
+ }
+ ]
+
\ No newline at end of file
diff --git a/examples/response/SetPtzPreset.json b/examples/response/SetPtzPreset.json
new file mode 100644
index 0000000..84121bc
--- /dev/null
+++ b/examples/response/SetPtzPreset.json
@@ -0,0 +1,10 @@
+[
+ {
+ "cmd" : "SetPtzPreset",
+ "code" : 0,
+ "value" : {
+ "rspCode" : 200
+ }
+ }
+ ]
+
\ No newline at end of file
diff --git a/examples/stream_gui.py b/examples/stream_gui.py
new file mode 100644
index 0000000..c174bf9
--- /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, 2400, 900)
+ 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/streaming_video.py b/examples/streaming_video.py
new file mode 100644
index 0000000..9049ed8
--- /dev/null
+++ b/examples/streaming_video.py
@@ -0,0 +1,81 @@
+import cv2
+from reolinkapi import Camera
+
+
+def non_blocking():
+ print("calling non-blocking")
+
+ def inner_callback(img):
+ cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600))
+ print("got the image non-blocking")
+ key = cv2.waitKey(1)
+ if key == ord('q'):
+ cv2.destroyAllWindows()
+ exit(1)
+
+ c = Camera("192.168.1.112", "admin", "jUa2kUzi")
+ # t in this case is a thread
+ t = c.open_video_stream(callback=inner_callback)
+
+ print(t.is_alive())
+ while True:
+ if not t.is_alive():
+ print("continuing")
+ break
+ # stop the stream
+ # client.stop_stream()
+
+
+def blocking():
+ c = Camera("192.168.1.112", "admin", "jUa2kUzi")
+ # stream in this case is a generator returning an image (in mat format)
+ stream = c.open_video_stream()
+
+ # using next()
+ # while True:
+ # img = next(stream)
+ # cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600))
+ # print("got the image blocking")
+ # key = cv2.waitKey(1)
+ # if key == ord('q'):
+ # cv2.destroyAllWindows()
+ # exit(1)
+
+ # or using a for loop
+ for img in stream:
+ cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600))
+ print("got the image blocking")
+ key = cv2.waitKey(1)
+ if key == ord('q'):
+ cv2.destroyAllWindows()
+ exit(1)
+
+
+# Resizes a image and maintains aspect ratio
+def maintain_aspect_ratio_resize(image, width=None, height=None, inter=cv2.INTER_AREA):
+ # Grab the image size and initialize dimensions
+ dim = None
+ (h, w) = image.shape[:2]
+
+ # Return original image if no need to resize
+ if width is None and height is None:
+ return image
+
+ # We are resizing height if width is none
+ if width is None:
+ # Calculate the ratio of the height and construct the dimensions
+ r = height / float(h)
+ dim = (int(w * r), height)
+ # We are resizing width if height is none
+ else:
+ # Calculate the ratio of the 0idth and construct the dimensions
+ r = width / float(w)
+ dim = (width, int(h * r))
+
+ # Return the resized image
+ return cv2.resize(image, dim, interpolation=inter)
+
+
+# Call the methods. Either Blocking (using generator) or Non-Blocking using threads
+# non_blocking()
+blocking()
diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py
new file mode 100644
index 0000000..6327dca
--- /dev/null
+++ b/examples/video_review_gui.py
@@ -0,0 +1,657 @@
+# Video review GUI
+# https://github.com/sven337/ReolinkLinux/wiki#reolink-video-review-gui
+
+import os
+import signal
+import sys
+import re
+import datetime
+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, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator
+from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
+from PyQt6.QtMultimediaWidgets import QVideoWidget
+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)
+
+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
+ # 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})'
+ 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))
+ 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")
+
+ 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):
+ 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, bool)
+ download_start = pyqtSignal(str)
+
+ def __init__(self, download_queue, cam):
+ super().__init__()
+ 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.popleft()
+ output_path = os.path.join(video_storage_dir, 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"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, bool)
+
+ def __init__(self, video_files):
+ super().__init__()
+ self.setWindowTitle("Reolink Video Review GUI")
+ self.cam = cam
+ 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)
+ 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_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_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")
+ 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)
+
+ 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)
+ 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_tree)
+ 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)
+ control_layout.addWidget(self.get_highres_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_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_tree.setSortingEnabled(False)
+ for file in file_dialog.selectedFiles():
+ self.add_video(os.path.basename(file))
+ 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 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)
+
+ output_path = os.path.join(video_storage_dir, base_file_name)
+ expected_size = parsed_data['file_size']
+
+ 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:
+ 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:
+ item.setIcon(1, self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical))
+
+
+ def on_download_start(self, video_path):
+ 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, file_name_item, column):
+ video_path = os.path.join(video_storage_dir, file_name_item.text(1))
+
+ 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)
+ 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 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()
+ 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(1000)
+ self.download_thread.terminate()
+ self.cam.logout()
+ 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)
+ cam.logout()
+ QApplication.quit()
+
+
+
+if __name__ == '__main__':
+ signal.signal(signal.SIGINT, signal_handler)
+
+ 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')
+ 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()
+
+ 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=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):
+ fname = motion['filename']
+ print("Processing %s" % (fname))
+ video_files.append(fname)
+
+ video_files.extend([os.path.basename(file) for file in args.files])
+ app = QApplication(sys.argv)
+ player = VideoPlayer(video_files)
+ player.resize(2400, 1000)
+ player.show()
+ sys.exit(app.exec())
diff --git a/make-and-publish-package.sh b/make-and-publish-package.sh
new file mode 100755
index 0000000..bf414e6
--- /dev/null
+++ b/make-and-publish-package.sh
@@ -0,0 +1,3 @@
+rm -rf dist
+python setup.py sdist bdist_wheel
+twine upload dist/*
\ No newline at end of file
diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py
new file mode 100644
index 0000000..ce35f76
--- /dev/null
+++ b/reolinkapi/__init__.py
@@ -0,0 +1,4 @@
+from reolinkapi.handlers.api_handler import APIHandler
+from .camera import Camera
+
+__version__ = "0.4.1"
diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py
new file mode 100644
index 0000000..7371b20
--- /dev/null
+++ b/reolinkapi/camera.py
@@ -0,0 +1,43 @@
+from reolinkapi.handlers.api_handler import APIHandler
+
+
+class Camera(APIHandler):
+
+ def __init__(self, ip: str,
+ username: str = "admin",
+ password: str = "",
+ https: bool = False,
+ defer_login: bool = False,
+ profile: str = "main",
+ **kwargs):
+ """
+ Initialise the Camera object by passing the ip address.
+ The default details {"username":"admin", "password":""} will be used if nothing passed
+ For deferring the login to the camera, just pass defer_login = True.
+ For connecting to the camera behind a proxy pass a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
+ :param ip:
+ :param username:
+ :param password:
+ :param https: connect to the camera over https
+ :param defer_login: defer the login process
+ :param proxy: Add a proxy dict for requests to consume.
+ eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...}
+ More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679
+ """
+ if profile not in ["main", "sub"]:
+ raise Exception("Profile argument must be either \"main\" or \"sub\"")
+
+ # For when you need to connect to a camera behind a proxy, pass
+ # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
+ APIHandler.__init__(self, ip, username, password, https=https, **kwargs)
+
+ # Normal call without proxy:
+ # APIHandler.__init__(self, ip, username, password)
+
+ self.ip = ip
+ self.username = username
+ self.password = password
+ self.profile = profile
+
+ if not defer_login:
+ super().login()
diff --git a/reolinkapi/handlers/__init__.py b/reolinkapi/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py
new file mode 100644
index 0000000..2ef7464
--- /dev/null
+++ b/reolinkapi/handlers/api_handler.py
@@ -0,0 +1,144 @@
+import requests
+from typing import Dict, List, Optional, Union
+from reolinkapi.mixins.alarm import AlarmAPIMixin
+from reolinkapi.mixins.device import DeviceAPIMixin
+from reolinkapi.mixins.display import DisplayAPIMixin
+from reolinkapi.mixins.download import DownloadAPIMixin
+from reolinkapi.mixins.image import ImageAPIMixin
+from reolinkapi.mixins.motion import MotionAPIMixin
+from reolinkapi.mixins.network import NetworkAPIMixin
+from reolinkapi.mixins.ptz import PtzAPIMixin
+from reolinkapi.mixins.record import RecordAPIMixin
+from reolinkapi.handlers.rest_handler import Request
+from reolinkapi.mixins.stream import StreamAPIMixin
+from reolinkapi.mixins.system import SystemAPIMixin
+from reolinkapi.mixins.user import UserAPIMixin
+from reolinkapi.mixins.zoom import ZoomAPIMixin
+from reolinkapi.mixins.nvrdownload import NvrDownloadAPIMixin
+
+
+class APIHandler(AlarmAPIMixin,
+ DeviceAPIMixin,
+ DisplayAPIMixin,
+ DownloadAPIMixin,
+ ImageAPIMixin,
+ MotionAPIMixin,
+ NetworkAPIMixin,
+ PtzAPIMixin,
+ RecordAPIMixin,
+ SystemAPIMixin,
+ UserAPIMixin,
+ ZoomAPIMixin,
+ StreamAPIMixin,
+ NvrDownloadAPIMixin):
+ """
+ The APIHandler class is the backend part of the API, the actual API calls
+ are implemented in Mixins.
+ This handles communication directly with the camera.
+ Current camera's tested: RLC-411WS
+
+ All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/
+ """
+
+ def __init__(self, ip: str, username: str, password: str, https: bool = False, **kwargs):
+ """
+ Initialise the Camera API Handler (maps api calls into python)
+ :param ip:
+ :param username:
+ :param password:
+ :param https: connect over https
+ :param proxy: Add a proxy dict for requests to consume.
+ eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...}
+ More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679
+ """
+ scheme = 'https' if https else 'http'
+ self.url = f"{scheme}://{ip}/cgi-bin/api.cgi"
+ self.ip = ip
+ self.token = None
+ self.username = username
+ self.password = password
+ Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found
+ Request.session = kwargs.get("session") # Defaults to None if key isn't found
+
+ def login(self) -> bool:
+ """
+ Get login token
+ Must be called first, before any other operation can be performed
+ :return: bool
+ """
+ try:
+ body = [{"cmd": "Login", "action": 0,
+ "param": {"User": {"userName": self.username, "password": self.password}}}]
+ param = {"cmd": "Login", "token": "null"}
+ response = Request.post(self.url, data=body, params=param)
+ if response is not None:
+ data = response.json()[0]
+ code = data["code"]
+ if int(code) == 0:
+ self.token = data["value"]["Token"]["name"]
+ print("Login success")
+ return True
+ print(self.token)
+ return False
+ else:
+ # TODO: Verify this change w/ owner. Delete old code if acceptable.
+ # A this point, response is NoneType. There won't be a status code property.
+ # print("Failed to login\nStatus Code:", response.status_code)
+ print("Failed to login\nResponse was null.")
+ return False
+ except Exception as e:
+ print("Error Login\n", e)
+ raise
+
+ def logout(self) -> bool:
+ """
+ Logout of the camera
+ :return: bool
+ """
+ try:
+ data = [{"cmd": "Logout", "action": 0}]
+ self._execute_command('Logout', data)
+ # print(ret)
+ return True
+ except Exception as e:
+ print("Error Logout\n", e)
+ return False
+
+ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) -> \
+ Optional[Union[Dict, bool]]:
+ """
+ Send a POST request to the IP camera with given data.
+ :param command: name of the command to send
+ :param data: object to send to the camera (send as json)
+ :param multi: whether the given command name should be added to the
+ url parameters of the request. Defaults to False. (Some multi-step
+ commands seem to not have a single command name)
+ :return: response JSON as python object
+ """
+ params = {"token": self.token, 'cmd': command}
+ if multi:
+ del params['cmd']
+ try:
+ if self.token is None:
+ raise ValueError("Login first")
+ if command == 'Download' or command == 'Playback':
+ # Special handling for downloading an mp4
+ # Pop the filepath from data
+ tgt_filepath = data[0].pop('filepath')
+ # Apply the data to the params
+ params.update(data[0])
+ with requests.get(self.url, params=params, stream=True, verify=False, timeout=(1, None), proxies=Request.proxies) as req:
+ if req.status_code == 200:
+ with open(tgt_filepath, 'wb') as f:
+ f.write(req.content)
+ return True
+ else:
+ print(f'Error received: {req.status_code}')
+ return False
+
+ else:
+ response = Request.post(self.url, data=data, params=params)
+ return response.json()
+ except Exception as e:
+ print(f"Command {command} failed: {e}")
+ raise
diff --git a/reolinkapi/handlers/rest_handler.py b/reolinkapi/handlers/rest_handler.py
new file mode 100644
index 0000000..a9512f4
--- /dev/null
+++ b/reolinkapi/handlers/rest_handler.py
@@ -0,0 +1,52 @@
+import requests
+from typing import List, Dict, Union, Optional
+
+
+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) -> \
+ Optional[requests.Response]:
+ """
+ Post request
+ :param params:
+ :param url:
+ :param data:
+ :return:
+ """
+ try:
+ headers = {'content-type': 'application/json'}
+ r = Request.__getSession().post(url, verify=False, params=params, json=data, headers=headers,
+ proxies=Request.proxies)
+ if r.status_code == 200:
+ return r
+ else:
+ raise ValueError(f"Http Request had non-200 Status: {r.status_code}", r.status_code)
+ except Exception as e:
+ print("Post Error\n", e)
+ raise
+
+ @staticmethod
+ def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> Optional[requests.Response]:
+ """
+ Get request
+ :param url:
+ :param params:
+ :param timeout:
+ :return:
+ """
+ try:
+ 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)
+ raise
diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/reolinkapi/mixins/alarm.py b/reolinkapi/mixins/alarm.py
new file mode 100644
index 0000000..53bc6ee
--- /dev/null
+++ b/reolinkapi/mixins/alarm.py
@@ -0,0 +1,14 @@
+from typing import Dict
+
+
+class AlarmAPIMixin:
+ """API calls for getting device alarm information."""
+
+ def get_alarm_motion(self) -> Dict:
+ """
+ Gets the device alarm motion
+ See examples/response/GetAlarmMotion.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetAlarm", "action": 1, "param": {"Alarm": {"channel": 0, "type": "md"}}}]
+ return self._execute_command('GetAlarm', body)
diff --git a/reolinkapi/mixins/device.py b/reolinkapi/mixins/device.py
new file mode 100644
index 0000000..21b7961
--- /dev/null
+++ b/reolinkapi/mixins/device.py
@@ -0,0 +1,49 @@
+from typing import List, Dict
+
+
+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
+ See examples/response/GetHddInfo.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}]
+ return self._execute_command('GetHddInfo', body)
+
+ def format_hdd(self, hdd_id: List[float] = None) -> bool:
+ """
+ Format specified HDD/SD cards with their id's
+ :param hdd_id: List of id's specified by the camera with get_hdd_info api. Default is 0 (SD card)
+ :return: bool
+ """
+ if hdd_id is None:
+ hdd_id = self.DEFAULT_HDD_ID
+ body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}]
+ r_data = self._execute_command('Format', body)[0]
+ if r_data["value"]["rspCode"] == 200:
+ return True
+ print("Could not format HDD/SD. Camera responded with:", r_data["value"])
+ return False
diff --git a/reolinkapi/mixins/display.py b/reolinkapi/mixins/display.py
new file mode 100644
index 0000000..c44c04b
--- /dev/null
+++ b/reolinkapi/mixins/display.py
@@ -0,0 +1,57 @@
+from typing import Dict
+
+
+class DisplayAPIMixin:
+ """API calls related to the current image (osd, on screen display)."""
+
+ def get_osd(self) -> Dict:
+ """
+ Get OSD information.
+ See examples/response/GetOsd.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}]
+ return self._execute_command('GetOsd', body)
+
+ def get_mask(self) -> Dict:
+ """
+ Get the camera mask information.
+ See examples/response/GetMask.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}]
+ return self._execute_command('GetMask', body)
+
+ def set_osd(self, bg_color: bool = 0, channel: float = 0, osd_channel_enabled: bool = 0,
+ osd_channel_name: str = "", osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0,
+ osd_time_pos: str = "Lower Right", osd_watermark_enabled: bool = 0) -> bool:
+ """
+ Set OSD
+ :param bg_color: bool
+ :param channel: int channel id
+ :param osd_channel_enabled: bool
+ :param osd_channel_name: string channel name
+ :param osd_channel_pos: string channel position
+ ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
+ :param osd_time_enabled: bool
+ :param osd_time_pos: string time position
+ ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
+ :return: whether the action was successful
+ """
+ body = [{"cmd": "SetOsd", "action": 1,
+ "param": {
+ "Osd": {
+ "bgcolor": bg_color,
+ "channel": channel,
+ "osdChannel": {
+ "enable": osd_channel_enabled, "name": osd_channel_name,
+ "pos": osd_channel_pos
+ },
+ "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos},
+ "watermark": osd_watermark_enabled,
+ }}}]
+ r_data = self._execute_command('SetOsd', body)[0]
+ if 'value' in r_data and r_data["value"]["rspCode"] == 200:
+ return True
+ print("Could not set OSD. Camera responded with status:", r_data["error"])
+ return False
diff --git a/reolinkapi/mixins/download.py b/reolinkapi/mixins/download.py
new file mode 100644
index 0000000..3580930
--- /dev/null
+++ b/reolinkapi/mixins/download.py
@@ -0,0 +1,21 @@
+class DownloadAPIMixin:
+ """API calls for downloading video files."""
+ 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": method,
+ "source": filename,
+ "output": filename,
+ "filepath": output_path
+ }
+ ]
+ resp = self._execute_command(method, body)
+
+ return resp
diff --git a/reolinkapi/mixins/image.py b/reolinkapi/mixins/image.py
new file mode 100644
index 0000000..8e30568
--- /dev/null
+++ b/reolinkapi/mixins/image.py
@@ -0,0 +1,103 @@
+from typing import Dict
+
+
+class ImageAPIMixin:
+ """API calls for image settings."""
+
+ def set_adv_image_settings(self,
+ anti_flicker: str = 'Outdoor',
+ exposure: str = 'Auto',
+ gain_min: float = 1,
+ gain_max: float = 62,
+ shutter_min: float = 1,
+ shutter_max: float = 125,
+ blue_gain: float = 128,
+ red_gain: float = 128,
+ white_balance: str = 'Auto',
+ day_night: str = 'Auto',
+ back_light: str = 'DynamicRangeControl',
+ blc: float = 128,
+ drc: float = 128,
+ rotation: float = 0,
+ mirroring: float = 0,
+ nr3d: float = 1) -> Dict:
+ """
+ Sets the advanced camera settings.
+
+ :param anti_flicker: string
+ :param exposure: string
+ :param gain_min: int
+ :param gain_max: string
+ :param shutter_min: int
+ :param shutter_max: int
+ :param blue_gain: int
+ :param red_gain: int
+ :param white_balance: string
+ :param day_night: string
+ :param back_light: string
+ :param blc: int
+ :param drc: int
+ :param rotation: int
+ :param mirroring: int
+ :param nr3d: int
+ :return: response
+ """
+ body = [{
+ "cmd": "SetIsp",
+ "action": 0,
+ "param": {
+ "Isp": {
+ "channel": 0,
+ "antiFlicker": anti_flicker,
+ "exposure": exposure,
+ "gain": {"min": gain_min, "max": gain_max},
+ "shutter": {"min": shutter_min, "max": shutter_max},
+ "blueGain": blue_gain,
+ "redGain": red_gain,
+ "whiteBalance": white_balance,
+ "dayNight": day_night,
+ "backLight": back_light,
+ "blc": blc,
+ "drc": drc,
+ "rotation": rotation,
+ "mirroring": mirroring,
+ "nr3d": nr3d
+ }
+ }
+ }]
+ return self._execute_command('SetIsp', body)
+
+ def set_image_settings(self,
+ brightness: float = 128,
+ contrast: float = 62,
+ hue: float = 1,
+ saturation: float = 125,
+ sharpness: float = 128) -> Dict:
+ """
+ Sets the camera image settings.
+
+ :param brightness: int
+ :param contrast: string
+ :param hue: int
+ :param saturation: int
+ :param sharpness: int
+ :return: response
+ """
+ body = [
+ {
+ "cmd": "SetImage",
+ "action": 0,
+ "param": {
+ "Image": {
+ "bright": brightness,
+ "channel": 0,
+ "contrast": contrast,
+ "hue": hue,
+ "saturation": saturation,
+ "sharpen": sharpness
+ }
+ }
+ }
+ ]
+
+ return self._execute_command('SetImage', body)
diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py
new file mode 100644
index 0000000..6aada63
--- /dev/null
+++ b/reolinkapi/mixins/motion.py
@@ -0,0 +1,85 @@
+from typing import Union, List, Dict
+from datetime import datetime as dt
+
+
+# Type hints for input and output of the motion api response
+RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, float, Dict[str, str]]]]
+PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, dt]]]
+
+
+class MotionAPIMixin:
+ """API calls for past motion alerts."""
+ def get_motion_files(self, start: dt, end: dt = dt.now(),
+ streamtype: str = 'sub', channel = 0) -> PROCESSED_MOTION_LIST_TYPE:
+ """
+ Get the timestamps and filenames of motion detection events for the time range provided.
+
+ Args:
+ start: the starting time range to examine
+ end: the end time of the time range to examine
+ streamtype: 'main' or 'sub' - the stream to examine
+ :return: response json
+ """
+ search_params = {
+ 'Search': {
+ 'channel': channel,
+ 'streamType': streamtype,
+ 'onlyStatus': 0,
+ 'StartTime': {
+ 'year': start.year,
+ 'mon': start.month,
+ 'day': start.day,
+ 'hour': start.hour,
+ 'min': start.minute,
+ 'sec': start.second
+ },
+ 'EndTime': {
+ 'year': end.year,
+ 'mon': end.month,
+ 'day': end.day,
+ 'hour': end.hour,
+ 'min': end.minute,
+ 'sec': end.second
+ }
+ }
+ }
+ body = [{"cmd": "Search", "action": 1, "param": search_params}]
+
+ resp = self._execute_command('Search', body)[0]
+ if 'value' not in resp:
+ return []
+ values = resp['value']
+ if 'SearchResult' not in values:
+ return []
+ result = values['SearchResult']
+ files = result.get('File', [])
+ if len(files) > 0:
+ # Begin processing files
+ processed_files = self._process_motion_files(files)
+ return processed_files
+ return []
+
+ @staticmethod
+ def _process_motion_files(motion_files: RAW_MOTION_LIST_TYPE) -> PROCESSED_MOTION_LIST_TYPE:
+ """Processes raw list of dicts containing motion timestamps
+ and the filename associated with them"""
+ # Process files
+ processed_motions = []
+ replace_fields = {'mon': 'month', 'sec': 'second', 'min': 'minute'}
+ for file in motion_files:
+ time_range = {}
+ for x in ['Start', 'End']:
+ # Get raw dict
+ raw = file[f'{x}Time']
+ # Replace certain keys
+ for k, v in replace_fields.items():
+ if k in raw.keys():
+ raw[v] = raw.pop(k)
+ time_range[x.lower()] = dt(**raw)
+ start, end = time_range.values()
+ processed_motions.append({
+ 'start': start,
+ 'end': end,
+ 'filename': file['name']
+ })
+ return processed_motions
diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py
new file mode 100644
index 0000000..ae44ba4
--- /dev/null
+++ b/reolinkapi/mixins/network.py
@@ -0,0 +1,175 @@
+from typing import Dict
+
+
+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:
+ """
+ Set network ports
+ If nothing is specified, the default values will be used
+ :param rtsp_port: int
+ :param rtmp_port: int
+ :param onvif_port: int
+ :param media_port: int
+ :param https_port: int
+ :type http_port: int
+ :return: bool
+ """
+ body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": {
+ "httpPort": http_port,
+ "httpsPort": https_port,
+ "mediaPort": media_port,
+ "onvifPort": onvif_port,
+ "rtmpPort": rtmp_port,
+ "rtspPort": rtsp_port
+ }}}]
+ self._execute_command('SetNetPort', body, multi=True)
+ print("Successfully Set Network Ports")
+ return True
+
+ def set_wifi(self, ssid: str, password: str) -> Dict:
+ body = [{"cmd": "SetWifi", "action": 0, "param": {
+ "Wifi": {
+ "ssid": ssid,
+ "password": password
+ }}}]
+ 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
+ See examples/response/GetNetworkAdvanced.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetNetPort", "action": 1, "param": {}},
+ {"cmd": "GetUpnp", "action": 0, "param": {}},
+ {"cmd": "GetP2p", "action": 0, "param": {}}]
+ return self._execute_command('GetNetPort', body, multi=True)
+
+ def get_wifi(self) -> Dict:
+ body = [{"cmd": "GetWifi", "action": 1, "param": {}}]
+ return self._execute_command('GetWifi', body)
+
+ def scan_wifi(self) -> Dict:
+ body = [{"cmd": "ScanWifi", "action": 1, "param": {}}]
+ return self._execute_command('ScanWifi', body)
+
+ def get_network_general(self) -> Dict:
+ """
+ Get the camera information
+ See examples/response/GetNetworkGeneral.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}]
+ return self._execute_command('GetLocalLink', body)
+
+ def get_network_ddns(self) -> Dict:
+ """
+ Get the camera DDNS network information
+ See examples/response/GetNetworkDDNS.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetDdns", "action": 0, "param": {}}]
+ return self._execute_command('GetDdns', body)
+
+ def get_network_ntp(self) -> Dict:
+ """
+ Get the camera NTP network information
+ See examples/response/GetNetworkNTP.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetNtp", "action": 0, "param": {}}]
+ return self._execute_command('GetNtp', body)
+
+ def get_network_email(self) -> Dict:
+ """
+ Get the camera email network information
+ See examples/response/GetNetworkEmail.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetEmail", "action": 0, "param": {}}]
+ return self._execute_command('GetEmail', body)
+
+ def get_network_ftp(self) -> Dict:
+ """
+ Get the camera FTP network information
+ See examples/response/GetNetworkFtp.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetFtp", "action": 0, "param": {}}]
+ return self._execute_command('GetFtp', body)
+
+ def get_network_push(self) -> Dict:
+ """
+ Get the camera push network information
+ See examples/response/GetNetworkPush.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetPush", "action": 0, "param": {}}]
+ return self._execute_command('GetPush', body)
+
+ def get_network_status(self) -> Dict:
+ """
+ Get the camera status network information
+ See examples/response/GetNetworkGeneral.json for example response data.
+ :return: response json
+ """
+ return self.get_network_general()
diff --git a/reolinkapi/mixins/nvrdownload.py b/reolinkapi/mixins/nvrdownload.py
new file mode 100644
index 0000000..c4e3051
--- /dev/null
+++ b/reolinkapi/mixins/nvrdownload.py
@@ -0,0 +1,54 @@
+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}]
+ 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']
+ if 'fileList' not in values:
+ return []
+ files = values['fileList']
+ if len(files) > 0:
+ return [file['fileName'] for file in files]
+ return []
diff --git a/reolinkapi/mixins/ptz.py b/reolinkapi/mixins/ptz.py
new file mode 100644
index 0000000..fd11dfc
--- /dev/null
+++ b/reolinkapi/mixins/ptz.py
@@ -0,0 +1,156 @@
+from typing import Dict
+
+
+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}
+ if index is not None:
+ param['id'] = index
+ data = [{"cmd": "PtzCtrl", "action": 0, "param": param}]
+ return self._execute_command('PtzCtrl', data)
+
+ def _send_noparm_operation(self, operation: str) -> Dict:
+ data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation}}]
+ 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": { "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:
+ """
+ Move the camera to a preset location
+ :return: response json
+ """
+ return self._send_operation('ToPos', speed=speed, index=index)
+
+ def add_preset(self, preset: float = 1, name: str = 'pos1') -> Dict:
+ """
+ Adds the current camera position to the specified preset.
+ :return: response json
+ """
+ return self._send_set_preset(enable=1, preset=preset, name=name)
+
+ def remove_preset(self, preset: float = 1, name: str = 'pos1') -> Dict:
+ """
+ Removes the specified preset
+ :return: response json
+ """
+ return self._send_set_preset(enable=0, preset=preset, name=name)
+
+ def move_right(self, speed: float = 25) -> Dict:
+ """
+ Move the camera to the right
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('Right', speed=speed)
+
+ def move_right_up(self, speed: float = 25) -> Dict:
+ """
+ Move the camera to the right and up
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('RightUp', speed=speed)
+
+ def move_right_down(self, speed: float = 25) -> Dict:
+ """
+ Move the camera to the right and down
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('RightDown', speed=speed)
+
+ def move_left(self, speed: float = 25) -> Dict:
+ """
+ Move the camera to the left
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('Left', speed=speed)
+
+ def move_left_up(self, speed: float = 25) -> Dict:
+ """
+ Move the camera to the left and up
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('LeftUp', speed=speed)
+
+ def move_left_down(self, speed: float = 25) -> Dict:
+ """
+ Move the camera to the left and down
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('LeftDown', speed=speed)
+
+ def move_up(self, speed: float = 25) -> Dict:
+ """
+ Move the camera up.
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('Up', speed=speed)
+
+ def move_down(self, speed: float = 25) -> Dict:
+ """
+ Move the camera down.
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('Down', speed=speed)
+
+ def stop_ptz(self) -> Dict:
+ """
+ Stops the cameras current action.
+ :return: response json
+ """
+ return self._send_noparm_operation('Stop')
+
+ def auto_movement(self, speed: float = 25) -> Dict:
+ """
+ Move the camera in a clockwise rotation.
+ The camera moves self.stop_ptz() is called.
+ :return: response json
+ """
+ return self._send_operation('Auto', speed=speed)
diff --git a/reolinkapi/mixins/record.py b/reolinkapi/mixins/record.py
new file mode 100644
index 0000000..375b5ca
--- /dev/null
+++ b/reolinkapi/mixins/record.py
@@ -0,0 +1,72 @@
+from typing import Dict
+
+
+class RecordAPIMixin:
+ """API calls for the recording settings"""
+
+ def get_recording_encoding(self) -> Dict:
+ """
+ Get the current camera encoding settings for "Clear" and "Fluent" profiles.
+ See examples/response/GetEnc.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}]
+ return self._execute_command('GetEnc', body)
+
+ def get_recording_advanced(self) -> Dict:
+ """
+ Get recording advanced setup data
+ See examples/response/GetRec.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}]
+ return self._execute_command('GetRec', body)
+
+ def set_recording_encoding(self,
+ audio: float = 0,
+ main_bit_rate: float = 8192,
+ main_frame_rate: float = 8,
+ main_profile: str = 'High',
+ main_size: str = "2560*1440",
+ sub_bit_rate: float = 160,
+ sub_frame_rate: float = 7,
+ sub_profile: str = 'High',
+ sub_size: str = '640*480') -> Dict:
+ """
+ Sets the current camera encoding settings for "Clear" and "Fluent" profiles.
+ :param audio: int Audio on or off
+ :param main_bit_rate: int Clear Bit Rate
+ :param main_frame_rate: int Clear Frame Rate
+ :param main_profile: string Clear Profile
+ :param main_size: string Clear Size
+ :param sub_bit_rate: int Fluent Bit Rate
+ :param sub_frame_rate: int Fluent Frame Rate
+ :param sub_profile: string Fluent Profile
+ :param sub_size: string Fluent Size
+ :return: response
+ """
+ body = [
+ {
+ "cmd": "SetEnc",
+ "action": 0,
+ "param": {
+ "Enc": {
+ "audio": audio,
+ "channel": 0,
+ "mainStream": {
+ "bitRate": main_bit_rate,
+ "frameRate": main_frame_rate,
+ "profile": main_profile,
+ "size": main_size
+ },
+ "subStream": {
+ "bitRate": sub_bit_rate,
+ "frameRate": sub_frame_rate,
+ "profile": sub_profile,
+ "size": sub_size
+ }
+ }
+ }
+ }
+ ]
+ return self._execute_command('SetEnc', body)
diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py
new file mode 100644
index 0000000..76dc239
--- /dev/null
+++ b/reolinkapi/mixins/stream.py
@@ -0,0 +1,67 @@
+import string
+from random import choices
+from typing import Any, Optional
+from urllib import parse
+from io import BytesIO
+
+import requests
+
+try:
+ from PIL.Image import Image, open as open_image
+
+ from reolinkapi.utils.rtsp_client import RtspClient
+
+
+ class StreamAPIMixin:
+ """ API calls for opening a video stream or capturing an image from the camera."""
+
+ def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any:
+ """
+ 'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player'
+ Blocking function creates a generator and returns the frames as it is spawned
+ :param callback:
+ :param proxies: Default is none, example: {"host": "localhost", "port": 8000}
+ """
+ rtsp_client = RtspClient(
+ ip=self.ip, username=self.username, password=self.password, profile=self.profile, proxies=proxies, callback=callback)
+ return rtsp_client.open_stream()
+
+ def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]:
+ """
+ Gets a "snap" of the current camera video data and returns a Pillow Image or None
+ :param timeout: Request timeout to camera in seconds
+ :param proxies: http/https proxies to pass to the request object.
+ :return: Image or None
+ """
+ data = {
+ 'cmd': 'Snap',
+ 'channel': 0,
+ 'rs': ''.join(choices(string.ascii_uppercase + string.digits, k=10)),
+ 'user': self.username,
+ 'password': self.password,
+ }
+ parms = parse.urlencode(data, safe="!").encode("utf-8")
+
+ try:
+ response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout)
+ if response.status_code == 200:
+ return open_image(BytesIO(response.content))
+ print("Could not retrieve data from camera successfully. Status:", response.status_code)
+ return None
+
+ except Exception as e:
+ print("Could not get Image data\n", e)
+ raise
+except ImportError 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(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(
+ f'get_snap requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"')
diff --git a/reolinkapi/mixins/system.py b/reolinkapi/mixins/system.py
new file mode 100644
index 0000000..dcb590a
--- /dev/null
+++ b/reolinkapi/mixins/system.py
@@ -0,0 +1,44 @@
+from typing import Dict
+
+
+class SystemAPIMixin:
+ """API for accessing general system information of the camera."""
+ def get_general_system(self) -> Dict:
+ """:return: response json"""
+ body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}]
+ return self._execute_command('get_general_system', body, multi=True)
+
+ def get_performance(self) -> Dict:
+ """
+ Get a snapshot of the current performance of the camera.
+ See examples/response/GetPerformance.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetPerformance", "action": 0, "param": {}}]
+ return self._execute_command('GetPerformance', body)
+
+ def get_information(self) -> Dict:
+ """
+ Get the camera information
+ See examples/response/GetDevInfo.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}]
+ return self._execute_command('GetDevInfo', body)
+
+ def reboot_camera(self) -> Dict:
+ """
+ Reboots the camera
+ :return: response json
+ """
+ body = [{"cmd": "Reboot", "action": 0, "param": {}}]
+ return self._execute_command('Reboot', body)
+
+ def get_dst(self) -> Dict:
+ """
+ Get the camera DST information
+ See examples/response/GetDSTInfo.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetTime", "action": 0, "param": {}}]
+ return self._execute_command('GetTime', body)
diff --git a/reolinkapi/mixins/user.py b/reolinkapi/mixins/user.py
new file mode 100644
index 0000000..c382c2d
--- /dev/null
+++ b/reolinkapi/mixins/user.py
@@ -0,0 +1,65 @@
+from typing import Dict
+
+
+class UserAPIMixin:
+ """User-related API calls."""
+ def get_online_user(self) -> Dict:
+ """
+ Return a list of current logged-in users in json format
+ See examples/response/GetOnline.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetOnline", "action": 1, "param": {}}]
+ return self._execute_command('GetOnline', body)
+
+ def get_users(self) -> Dict:
+ """
+ Return a list of user accounts from the camera in json format.
+ See examples/response/GetUser.json for example response data.
+ :return: response json
+ """
+ body = [{"cmd": "GetUser", "action": 1, "param": {}}]
+ return self._execute_command('GetUser', body)
+
+ def add_user(self, username: str, password: str, level: str = "guest") -> bool:
+ """
+ Add a new user account to the camera
+ :param username: The user's username
+ :param password: The user's password
+ :param level: The privilege level 'guest' or 'admin'. Default is 'guest'
+ :return: whether the user was added successfully
+ """
+ body = [{"cmd": "AddUser", "action": 0,
+ "param": {"User": {"userName": username, "password": password, "level": level}}}]
+ r_data = self._execute_command('AddUser', body)[0]
+ if r_data["value"]["rspCode"] == 200:
+ return True
+ print("Could not add user. Camera responded with:", r_data["value"])
+ return False
+
+ def modify_user(self, username: str, password: str) -> bool:
+ """
+ Modify the user's password by specifying their username
+ :param username: The user which would want to be modified
+ :param password: The new password
+ :return: whether the user was modified successfully
+ """
+ body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}]
+ r_data = self._execute_command('ModifyUser', body)[0]
+ if r_data["value"]["rspCode"] == 200:
+ return True
+ print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}")
+ return False
+
+ def delete_user(self, username: str) -> bool:
+ """
+ Delete a user by specifying their username
+ :param username: The user which would want to be deleted
+ :return: whether the user was deleted successfully
+ """
+ body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}]
+ r_data = self._execute_command('DelUser', body)[0]
+ if r_data["value"]["rspCode"] == 200:
+ return True
+ print(f"Could not delete user: {username}\nCamera responded with: {r_data['value']}")
+ return False
diff --git a/reolinkapi/mixins/zoom.py b/reolinkapi/mixins/zoom.py
new file mode 100644
index 0000000..74549e0
--- /dev/null
+++ b/reolinkapi/mixins/zoom.py
@@ -0,0 +1,84 @@
+from typing import Dict
+
+
+class ZoomAPIMixin:
+ """
+ API for zooming and changing focus.
+ Note that the API does not allow zooming/focusing by absolute
+ values rather that changing focus/zoom for a given time.
+ """
+ def _start_operation(self, operation: str, speed: float) -> Dict:
+ data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}]
+ return self._execute_command('PtzCtrl', data)
+
+ def _stop_zooming_or_focusing(self) -> Dict:
+ """This command stops any ongoing zooming or focusing actions."""
+ data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}]
+ return self._execute_command('PtzCtrl', data)
+
+ def 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.
+ :return: response json
+ """
+ return self._start_operation('ZoomInc', speed=speed)
+
+ def start_zooming_out(self, speed: float = 60) -> Dict:
+ """
+ The camera zooms out until self.stop_zooming() is called.
+ :return: response json
+ """
+ return self._start_operation('ZoomDec', speed=speed)
+
+ def stop_zooming(self) -> Dict:
+ """
+ Stop zooming.
+ :return: response json
+ """
+ return self._stop_zooming_or_focusing()
+
+ def start_focusing_in(self, speed: float = 32) -> Dict:
+ """
+ The camera focuses in until self.stop_focusing() is called.
+ :return: response json
+ """
+ return self._start_operation('FocusInc', speed=speed)
+
+ def start_focusing_out(self, speed: float = 32) -> Dict:
+ """
+ The camera focuses out until self.stop_focusing() is called.
+ :return: response json
+ """
+ return self._start_operation('FocusDec', speed=speed)
+
+ def stop_focusing(self) -> Dict:
+ """
+ Stop focusing.
+ :return: response json
+ """
+ return self._stop_zooming_or_focusing()
diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/reolinkapi/utils/rtsp_client.py b/reolinkapi/utils/rtsp_client.py
new file mode 100644
index 0000000..e260a74
--- /dev/null
+++ b/reolinkapi/utils/rtsp_client.py
@@ -0,0 +1,105 @@
+import os
+from threading import ThreadError
+from typing import Any
+import cv2
+from reolinkapi.utils.util import threaded
+
+
+class RtspClient:
+ """
+ This is a wrapper of the OpenCV VideoCapture method
+ Inspiration from:
+ - https://benhowell.github.io/guide/2015/03/09/opencv-and-web-cam-streaming
+ - https://stackoverflow.com/questions/19846332/python-threading-inside-a-class
+ - https://stackoverflow.com/questions/55828451/video-streaming-from-ip-camera-in-python-using-opencv-cv2-videocapture
+ """
+
+ def __init__(self, ip: str, username: str, password: str, port: float = 554, profile: str = "main",
+ use_udp: bool = True, callback: Any = None, **kwargs):
+ """
+ RTSP client is used to retrieve frames from the camera in a stream
+
+ :param ip: Camera IP
+ :param username: Camera Username
+ :param password: Camera User Password
+ :param port: RTSP port
+ :param profile: "main" or "sub"
+ :param use_upd: True to use UDP, False to use TCP
+ :param proxies: {"host": "localhost", "port": 8000}
+ """
+ self.capture = None
+ self.thread_cancelled = False
+ self.callback = callback
+
+ capture_options = 'rtsp_transport;'
+ self.ip = ip
+ self.username = username
+ self.password = password
+ self.port = port
+ self.proxy = kwargs.get("proxies")
+ self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}'
+ capture_options += 'udp' if use_udp else 'tcp'
+
+ os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options
+
+ # opens the stream capture, but does not retrieve any frames yet.
+ self._open_video_capture()
+
+ def _open_video_capture(self):
+ # To CAP_FFMPEG or not To ?
+ self.capture = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG)
+
+ def _stream_blocking(self):
+ while True:
+ try:
+ if self.capture.isOpened():
+ ret, frame = self.capture.read()
+ if ret:
+ yield frame
+ else:
+ print("stream closed")
+ self.capture.release()
+ return
+ except Exception as e:
+ print(e)
+ self.capture.release()
+ return
+
+ @threaded
+ def _stream_non_blocking(self):
+ while not self.thread_cancelled:
+ try:
+ if self.capture.isOpened():
+ ret, frame = self.capture.read()
+ if ret:
+ self.callback(frame)
+ else:
+ print("stream is closed")
+ self.stop_stream()
+ except ThreadError as e:
+ print(e)
+ self.stop_stream()
+
+ def stop_stream(self):
+ self.capture.release()
+ self.thread_cancelled = True
+
+ def open_stream(self):
+ """
+ Opens OpenCV Video stream and returns the result according to the OpenCV documentation
+ https://docs.opencv.org/3.4/d8/dfe/classcv_1_1VideoCapture.html#a473055e77dd7faa4d26d686226b292c1
+ """
+
+ # Reset the capture object
+ if self.capture is None or not self.capture.isOpened():
+ self._open_video_capture()
+
+ print("opening stream")
+
+ if self.callback is None:
+ return self._stream_blocking()
+ else:
+ # reset the thread status if the object was not re-created
+ if not self.thread_cancelled:
+ self.thread_cancelled = False
+ return self._stream_non_blocking()
diff --git a/reolinkapi/utils/util.py b/reolinkapi/utils/util.py
new file mode 100644
index 0000000..83cf0ba
--- /dev/null
+++ b/reolinkapi/utils/util.py
@@ -0,0 +1,11 @@
+from threading import Thread
+
+
+def threaded(fn):
+ def wrapper(*args, **kwargs):
+ thread = Thread(target=fn, args=args, kwargs=kwargs)
+ thread.daemon = True
+ thread.start()
+ return thread
+
+ return wrapper
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..945c9b4
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+.
\ No newline at end of file
diff --git a/resthandle.py b/resthandle.py
deleted file mode 100644
index e632686..0000000
--- a/resthandle.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import json
-
-import requests
-
-
-class Request:
-
- @staticmethod
- def post(url: str, data, params=None) -> requests.Response or None:
- """
- Post request
- :param params:
- :param url:
- :param data:
- :return:
- """
- try:
- headers = {'content-type': 'application/json'}
- if params is not None:
- r = requests.post(url, params=params, json=data, headers=headers)
- else:
- r = requests.post(url, json=data)
- if r.status_code == 200:
- return r
- else:
- raise ValueError("Status: ", r.status_code)
- except Exception as e:
- print("Post Error\n", e)
- raise
-
- @staticmethod
- def get(url, params, timeout=1) -> json or None:
- """
- Get request
- :param url:
- :param params:
- :param timeout:
- :return:
- """
- try:
- data = requests.get(url=url, params=params, timeout=timeout)
- return data
- except Exception as e:
- print("Get Error\n", e)
- raise
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..b36aaf8
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,63 @@
+#!/usr/bin/python3
+import os
+import re
+import codecs
+from setuptools import setup, find_packages
+
+
+def read(*parts):
+ with codecs.open(os.path.join(here, *parts), 'r') as fp:
+ return fp.read()
+
+
+def find_version(*file_paths):
+ version_file = read(*file_paths)
+ version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
+ if version_match:
+ return version_match.group(1)
+ raise RuntimeError("Unable to find version string.")
+
+
+# Package meta-data.
+NAME = 'reolinkapi'
+DESCRIPTION = 'Reolink Camera API client written in Python 3'
+URL = 'https://github.com/ReolinkCameraAPI/reolinkapipy'
+AUTHOR_EMAIL = 'alanoterblanche@gmail.com'
+AUTHOR = 'Benehiko'
+LICENSE = 'GPL-3.0'
+INSTALL_REQUIRES = [
+ 'setuptools',
+ 'PySocks==1.7.1',
+ 'PyYaml==6.0.2',
+ 'requests>=2.32.3',
+]
+EXTRAS_REQUIRE = {
+ 'streaming': [
+ 'numpy==2.0.1',
+ 'opencv-python==4.10.0.84',
+ 'Pillow==10.4.0',
+ ],
+}
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+# read the contents of your README file
+with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
+ long_description = f.read()
+
+
+setup(
+ name=NAME,
+ python_requires='>=3.12.4',
+ version=find_version('reolinkapi', '__init__.py'),
+ description=DESCRIPTION,
+ long_description=long_description,
+ long_description_content_type='text/markdown',
+ author=AUTHOR,
+ author_email=AUTHOR_EMAIL,
+ url=URL,
+ license=LICENSE,
+ install_requires=INSTALL_REQUIRES,
+ packages=find_packages(exclude=['examples', 'tests']),
+ extras_require=EXTRAS_REQUIRE
+)
diff --git a/test.py b/test.py
deleted file mode 100644
index 158ad3f..0000000
--- a/test.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from Camera import Camera
-
-c = Camera("192.168.1.100", "admin", "jUa2kUzi")
-c.get_wifi()
-c.scan_wifi()
diff --git a/tests/test_camera.py b/tests/test_camera.py
new file mode 100644
index 0000000..c5cdf98
--- /dev/null
+++ b/tests/test_camera.py
@@ -0,0 +1,46 @@
+import os
+from configparser import RawConfigParser
+import unittest
+from reolinkapi import Camera
+
+
+def read_config(props_path: str) -> dict:
+ """Reads in a properties file into variables.
+
+ NB! this config file is kept out of commits with .gitignore. The structure of this file is such:
+ # secrets.cfg
+ [camera]
+ ip={ip_address}
+ username={username}
+ password={password}
+ """
+ config = RawConfigParser()
+ assert os.path.exists(props_path), f"Path does not exist: {props_path}"
+ config.read(props_path)
+ return config
+
+
+class TestCamera(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.config = read_config('../secrets.cfg')
+
+ def setUp(self) -> None:
+ self.cam = Camera(self.config.get('camera', 'ip'), self.config.get('camera', 'username'),
+ self.config.get('camera', 'password'))
+
+ def test_camera(self):
+ """Test that camera connects and gets a token"""
+ self.assertTrue(self.cam.ip == self.config.get('camera', 'ip'))
+ self.assertTrue(self.cam.token != '')
+
+ 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()