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 368d535..c0c4d43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+secrets.cfg
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -58,3 +59,4 @@ docs/_build/
target/
.idea/
+venv/
\ No newline at end of file
diff --git a/Camera.py b/Camera.py
deleted file mode 100644
index a60490a..0000000
--- a/Camera.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from api import APIHandler
-
-
-class Camera(APIHandler):
-
- def __init__(self, ip, username="admin", password="", https=False):
- """
- Initialise the Camera object by passing the ip address.
- The default details {"username":"admin", "password":""} will be used if nothing passed
- :param ip:
- :param username:
- :param password:
- """
- # For when you need to connect to a camera behind a proxy, pass
- # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"}
- APIHandler.__init__(self, ip, username, password, https=https)
-
- # Normal call without proxy:
- # APIHandler.__init__(self, ip, username, password)
-
- self.ip = ip
- self.username = username
- self.password = password
- super().login()
diff --git a/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.lock b/Pipfile.lock
deleted file mode 100644
index 15b8698..0000000
--- a/Pipfile.lock
+++ /dev/null
@@ -1,169 +0,0 @@
-{
- "_meta": {
- "hash": {
- "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a"
- },
- "pipfile-spec": 6,
- "requires": {},
- "sources": [
- {
- "name": "pypi",
- "url": "https://pypi.org/simple",
- "verify_ssl": true
- }
- ]
- },
- "default": {
- "certifi": {
- "hashes": [
- "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
- "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
- ],
- "version": "==2019.11.28"
- },
- "chardet": {
- "hashes": [
- "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
- "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
- ],
- "version": "==3.0.4"
- },
- "idna": {
- "hashes": [
- "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
- "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
- ],
- "version": "==2.9"
- },
- "numpy": {
- "hashes": [
- "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6",
- "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e",
- "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc",
- "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc",
- "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a",
- "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa",
- "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3",
- "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121",
- "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971",
- "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26",
- "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd",
- "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480",
- "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec",
- "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77",
- "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57",
- "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07",
- "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572",
- "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73",
- "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca",
- "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474",
- "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5"
- ],
- "index": "pypi",
- "version": "==1.18.1"
- },
- "opencv-python": {
- "hashes": [
- "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3",
- "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50",
- "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb",
- "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b",
- "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d",
- "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a",
- "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d",
- "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951",
- "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c",
- "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098",
- "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20",
- "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f",
- "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145",
- "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee",
- "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b",
- "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e",
- "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946",
- "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db",
- "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3",
- "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268",
- "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179",
- "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2",
- "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2",
- "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9",
- "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944",
- "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00",
- "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417"
- ],
- "index": "pypi",
- "version": "==4.2.0.32"
- },
- "pillow": {
- "hashes": [
- "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be",
- "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946",
- "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837",
- "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f",
- "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00",
- "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d",
- "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533",
- "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a",
- "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358",
- "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda",
- "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435",
- "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2",
- "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313",
- "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff",
- "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317",
- "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2",
- "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614",
- "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0",
- "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386",
- "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9",
- "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636",
- "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865"
- ],
- "index": "pypi",
- "version": "==7.0.0"
- },
- "pysocks": {
- "hashes": [
- "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
- "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
- "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
- ],
- "index": "pypi",
- "version": "==1.7.1"
- },
- "pyyaml": {
- "hashes": [
- "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6",
- "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf",
- "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5",
- "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e",
- "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811",
- "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e",
- "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d",
- "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20",
- "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689",
- "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994",
- "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"
- ],
- "index": "pypi",
- "version": "==5.3"
- },
- "requests": {
- "hashes": [
- "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
- "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
- ],
- "index": "pypi",
- "version": "==2.23.0"
- },
- "urllib3": {
- "hashes": [
- "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
- "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
- ],
- "version": "==1.25.8"
- }
- },
- "develop": {}
-}
diff --git a/README.md b/README.md
index cd90018..87a205f 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,88 @@
-## ReolinkCameraAPI
+
Reolink Python Api Client
-### Join us on Discord
+
+
+
+
+
+
+
- https://discord.gg/8z3fdAmZJP
+---
+
+A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Cameras,
+although they have a basic API document - it does not satisfy the need for extensive camera communication.
+
+Check out our documentation for more information on how to use the software at [https://reolink.oleaintueri.com](https://reolink.oleaintueri.com)
-### Purpose
-This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS )
+Other Supported Languages:
+ - Go: [reolinkapigo](https://github.com/ReolinkCameraAPI/reolinkapigo)
+
+### Join us on Discord
+
+ https://discord.gg/8z3fdAmZJP
+
+### Sponsorship
-### But Reolink gives an API in their documentation
+
-Not really. They only deliver a really basic API to retrieve Image data and Video data.
+[Oleaintueri](https://oleaintueri.com) is sponsoring the development and maintenance of these projects within their organisation.
-### How?
-You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network.
+---
### Get started
Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands.
+See the `examples` directory.
+
### Using the library as a Python Module
-Install the package via Pip
+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
- pip install reolink-api==0.0.1
+---
### Styling and Standards
This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/)
+### 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
- [X] Logout
@@ -58,8 +108,9 @@ GET:
- [X] User -> Add User
- [X] User -> Manage User
- [X] Device -> HDD/SD Card
-- [ ] Zoom
-- [ ] Focus
+- [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)
- [X] Image Data -> "Snap" Frame from Video Stream
@@ -84,8 +135,25 @@ SET:
- [X] User -> Add User
- [X] User -> Manage User
- [X] Device -> HDD/SD Card (Format)
-- [x] PTZ
+- [x] PTZ (including calibrate)
- [x] Zoom
- [x] Focus
- [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate)
- [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/RtspClient.py b/RtspClient.py
deleted file mode 100644
index 06f2e04..0000000
--- a/RtspClient.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import os
-import cv2
-
-
-class RtspClient:
-
- def __init__(self, ip, username, password, port=554, profile="main", use_udp=True, **kwargs):
- """
-
- :param ip: Camera IP
- :param username: Camera Username
- :param password: Camera User Password
- :param port: RTSP port
- :param profile: "main" or "sub"
- :param use_upd: True to use UDP, False to use TCP
- :param proxies: {"host": "localhost", "port": 8000}
- """
- capture_options = 'rtsp_transport;'
- self.ip = ip
- self.username = username
- self.password = password
- self.port = port
- self.proxy = kwargs.get("proxies")
- self.url = "rtsp://" + self.username + ":" + self.password + "@" + \
- self.ip + ":" + str(self.port) + "//h264Preview_01_" + profile
- if use_udp:
- capture_options = capture_options + 'udp'
- else:
- capture_options = capture_options + 'tcp'
-
- os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options
-
- def preview(self):
- """ Blocking function. Opens OpenCV window to display stream. """
- win_name = self.ip
- cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG)
- ret, frame = cap.read()
-
- while ret:
- cv2.imshow(win_name, frame)
-
- ret, frame = cap.read()
- if (cv2.waitKey(1) & 0xFF == ord('q')):
- break
-
- cap.release()
- cv2.destroyAllWindows()
diff --git a/api/__init__.py b/api/__init__.py
deleted file mode 100644
index 916ada9..0000000
--- a/api/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .APIHandler import APIHandler
-
-__version__ = "0.0.4"
-VERSION = __version__
diff --git a/api/device.py b/api/device.py
deleted file mode 100644
index deee890..0000000
--- a/api/device.py
+++ /dev/null
@@ -1,23 +0,0 @@
-class DeviceAPIMixin:
- """API calls for getting device information."""
- def get_hdd_info(self) -> object:
- """
- Gets all HDD and SD card information from Camera
- See examples/response/GetHddInfo.json for example response data.
- :return: response json
- """
- body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}]
- return self._execute_command('GetHddInfo', body)
-
- def format_hdd(self, hdd_id: [int] = [0]) -> bool:
- """
- Format specified HDD/SD cards with their id's
- :param hdd_id: List of id's specified by the camera with get_hdd_info api. Default is 0 (SD card)
- :return: bool
- """
- body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}]
- r_data = self._execute_command('Format', body)[0]
- if r_data["value"]["rspCode"] == 200:
- return True
- print("Could not format HDD/SD. Camera responded with:", r_data["value"])
- return False
diff --git a/api/display.py b/api/display.py
deleted file mode 100644
index bf2b4ae..0000000
--- a/api/display.py
+++ /dev/null
@@ -1,47 +0,0 @@
-class DisplayAPIMixin:
- """API calls related to the current image (osd, on screen display)."""
-
- def get_osd(self) -> object:
- """
- Get OSD information.
- See examples/response/GetOsd.json for example response data.
- :return: response json
- """
- body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}]
- return self._execute_command('GetOsd', body)
-
- def get_mask(self) -> object:
- """
- Get the camera mask information.
- See examples/response/GetMask.json for example response data.
- :return: response json
- """
- body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}]
- return self._execute_command('GetMask', body)
-
- def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, osd_channel_name: str = "",
- osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0,
- osd_time_pos: str = "Lower Right") -> bool:
- """
- Set OSD
- :param bg_color: bool
- :param channel: int channel id
- :param osd_channel_enabled: bool
- :param osd_channel_name: string channel name
- :param osd_channel_pos: string channel position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
- :param osd_time_enabled: bool
- :param osd_time_pos: string time position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"]
- :return: whether the action was successful
- """
- body = [{"cmd": "SetOsd", "action": 1, "param": {
- "Osd": {"bgcolor": bg_color, "channel": channel,
- "osdChannel": {"enable": osd_channel_enabled, "name": osd_channel_name,
- "pos": osd_channel_pos},
- "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos}
- }
- }}]
- r_data = self._execute_command('SetOsd', body)[0]
- if r_data["value"]["rspCode"] == 200:
- return True
- print("Could not set OSD. Camera responded with status:", r_data["value"])
- return False
diff --git a/api/ptz.py b/api/ptz.py
deleted file mode 100644
index 463624e..0000000
--- a/api/ptz.py
+++ /dev/null
@@ -1,120 +0,0 @@
-class PtzAPIMixin:
- """
- API for PTZ functions.
- """
- def _send_operation(self, operation, speed, index=None):
- if index is None:
- data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}]
- else:
- data = [{"cmd": "PtzCtrl", "action": 0, "param": {
- "channel": 0, "op": operation, "speed": speed, "id": index}}]
- return self._execute_command('PtzCtrl', data)
-
- def _send_noparm_operation(self, operation):
- data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation}}]
- return self._execute_command('PtzCtrl', data)
-
- def _send_set_preset(self, operation, enable, preset=1, name='pos1'):
- data = [{"cmd": "SetPtzPreset", "action": 0, "param": {
- "channel": 0, "enable": enable, "id": preset, "name": name}}]
- return self._execute_command('PtzCtrl', data)
-
- def go_to_preset(self, speed=60, index=1):
- """
- Move the camera to a preset location
- :return: response json
- """
- return self._send_operation('ToPos', speed=speed, index=index)
-
- def add_preset(self, preset=1, name='pos1'):
- """
- Adds the current camera position to the specified preset.
- :return: response json
- """
- return self._send_set_preset('PtzPreset', enable=1, preset=preset, name=name)
-
- def remove_preset(self, preset=1, name='pos1'):
- """
- Removes the specified preset
- :return: response json
- """
- return self._send_set_preset('PtzPreset', enable=0, preset=preset, name=name)
-
- def move_right(self, speed=25):
- """
- 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=25):
- """
- 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=25):
- """
- 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=25):
- """
- 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=25):
- """
- 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=25):
- """
- 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=25):
- """
- 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=25):
- """
- Move the camera down.
- The camera moves self.stop_ptz() is called.
- :return: response json
- """
- return self._send_operation('Down', speed=speed)
-
- def stop_ptz(self):
- """
- Stops the cameras current action.
- :return: response json
- """
- return self._send_noparm_operation('Stop')
-
- def auto_movement(self, speed=25):
- """
- 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/api/recording.py b/api/recording.py
deleted file mode 100644
index 259e56a..0000000
--- a/api/recording.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import requests
-import random
-import string
-from urllib import parse
-from io import BytesIO
-from PIL import Image
-from RtspClient import RtspClient
-
-
-class RecordingAPIMixin:
- """API calls for recording/streaming image or video."""
- def get_recording_encoding(self) -> object:
- """
- Get the current camera encoding settings for "Clear" and "Fluent" profiles.
- See examples/response/GetEnc.json for example response data.
- :return: response json
- """
- body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}]
- return self._execute_command('GetEnc', body)
-
- def get_recording_advanced(self) -> object:
- """
- Get recording advanced setup data
- See examples/response/GetRec.json for example response data.
- :return: response json
- """
- body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}]
- return self._execute_command('GetRec', body)
-
- def set_recording_encoding(self,
- audio=0,
- main_bit_rate=8192,
- main_frame_rate=8,
- main_profile='High',
- main_size="2560*1440",
- sub_bit_rate=160,
- sub_frame_rate=7,
- sub_profile='High',
- sub_size='640*480') -> object:
- """
- Sets the current camera encoding settings for "Clear" and "Fluent" profiles.
- :param audio: int Audio on or off
- :param main_bit_rate: int Clear Bit Rate
- :param main_frame_rate: int Clear Frame Rate
- :param main_profile: string Clear Profile
- :param main_size: string Clear Size
- :param sub_bit_rate: int Fluent Bit Rate
- :param sub_frame_rate: int Fluent Frame Rate
- :param sub_profile: string Fluent Profile
- :param sub_size: string Fluent Size
- :return: response
- """
- body = [{"cmd": "SetEnc",
- "action": 0,
- "param":
- {"Enc":
- {"audio": audio,
- "channel": 0,
- "mainStream": {
- "bitRate": main_bit_rate,
- "frameRate": main_frame_rate,
- "profile": main_profile,
- "size": main_size},
- "subStream": {
- "bitRate": sub_bit_rate,
- "frameRate": sub_frame_rate,
- "profile": sub_profile,
- "size": sub_size}}
- }}]
- return self._execute_command('SetEnc', body)
-
- ###########
- # RTSP Stream
- ###########
- def open_video_stream(self, profile: str = "main", proxies=None) -> None:
- """
- 'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player'
- :param profile: profile is "main" or "sub"
- :param proxies: Default is none, example: {"host": "localhost", "port": 8000}
- """
- rtsp_client = RtspClient(
- ip=self.ip, username=self.username, password=self.password, proxies=proxies)
- rtsp_client.preview()
-
- def get_snap(self, timeout: int = 3, proxies=None) -> Image or None:
- """
- Gets a "snap" of the current camera video data and returns a Pillow Image or None
- :param timeout: Request timeout to camera in seconds
- :param proxies: http/https proxies to pass to the request object.
- :return: Image or None
- """
- data = {}
- data['cmd'] = 'Snap'
- data['channel'] = 0
- data['rs'] = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
- data['user'] = self.username
- data['password'] = self.password
- parms = parse.urlencode(data).encode("utf-8")
-
- try:
- response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout)
- if response.status_code == 200:
- return Image.open(BytesIO(response.content))
- print("Could not retrieve data from camera successfully. Status:", response.stats_code)
- return None
-
- except Exception as e:
- print("Could not get Image data\n", e)
- raise
diff --git a/api/zoom.py b/api/zoom.py
deleted file mode 100644
index 2bf0021..0000000
--- a/api/zoom.py
+++ /dev/null
@@ -1,56 +0,0 @@
-class ZoomAPIMixin:
- """
- API for zooming and changing focus.
- Note that the API does not allow zooming/focusing by absolute
- values rather that changing focus/zoom for a given time.
- """
- def _start_operation(self, operation, speed):
- data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}]
- return self._execute_command('PtzCtrl', data)
-
- def _stop_zooming_or_focusing(self):
- """This command stops any ongoing zooming or focusing actions."""
- data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}]
- return self._execute_command('PtzCtrl', data)
-
- def start_zooming_in(self, speed=60):
- """
- The camera zooms in until self.stop_zooming() is called.
- :return: response json
- """
- return self._start_operation('ZoomInc', speed=speed)
-
- def start_zooming_out(self, speed=60):
- """
- The camera zooms out until self.stop_zooming() is called.
- :return: response json
- """
- return self._start_operation('ZoomDec', speed=speed)
-
- def stop_zooming(self):
- """
- Stop zooming.
- :return: response json
- """
- return self._stop_zooming_or_focusing()
-
- def start_focusing_in(self, speed=32):
- """
- The camera focuses in until self.stop_focusing() is called.
- :return: response json
- """
- return self._start_operation('FocusInc', speed=speed)
-
- def start_focusing_out(self, speed=32):
- """
- The camera focuses out until self.stop_focusing() is called.
- :return: response json
- """
- return self._start_operation('FocusDec', speed=speed)
-
- def stop_focusing(self):
- """
- Stop focusing.
- :return: response json
- """
- return self._stop_zooming_or_focusing()
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/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/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/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
index 43cca45..bf414e6 100755
--- a/make-and-publish-package.sh
+++ b/make-and-publish-package.sh
@@ -1,3 +1,3 @@
-rm -fr dist
-python setup.py sdist
+rm -rf dist
+python setup.py sdist bdist_wheel
twine upload dist/*
\ No newline at end of file
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/api/APIHandler.py b/reolinkapi/handlers/api_handler.py
similarity index 56%
rename from api/APIHandler.py
rename to reolinkapi/handlers/api_handler.py
index a4a6f07..2ef7464 100644
--- a/api/APIHandler.py
+++ b/reolinkapi/handlers/api_handler.py
@@ -1,26 +1,36 @@
-from .recording import RecordingAPIMixin
-from .zoom import ZoomAPIMixin
-from .device import DeviceAPIMixin
-from .display import DisplayAPIMixin
-from .network import NetworkAPIMixin
-from .system import SystemAPIMixin
-from .user import UserAPIMixin
-from .ptz import PtzAPIMixin
-from .alarm import AlarmAPIMixin
-from .image import ImageAPIMixin
-from resthandle import Request
+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(SystemAPIMixin,
- NetworkAPIMixin,
- UserAPIMixin,
+class APIHandler(AlarmAPIMixin,
DeviceAPIMixin,
DisplayAPIMixin,
- RecordingAPIMixin,
- ZoomAPIMixin,
+ DownloadAPIMixin,
+ ImageAPIMixin,
+ MotionAPIMixin,
+ NetworkAPIMixin,
PtzAPIMixin,
- AlarmAPIMixin,
- ImageAPIMixin):
+ RecordAPIMixin,
+ SystemAPIMixin,
+ UserAPIMixin,
+ ZoomAPIMixin,
+ StreamAPIMixin,
+ NvrDownloadAPIMixin):
"""
The APIHandler class is the backend part of the API, the actual API calls
are implemented in Mixins.
@@ -30,12 +40,13 @@ class APIHandler(SystemAPIMixin,
All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/
"""
- def __init__(self, ip: str, username: str, password: str, https=False, **kwargs):
+ 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
@@ -47,6 +58,7 @@ def __init__(self, ip: str, username: str, password: str, https=False, **kwargs)
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:
"""
@@ -69,7 +81,10 @@ def login(self) -> bool:
print(self.token)
return False
else:
- print("Failed to login\nStatus Code:", response.status_code)
+ # 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)
@@ -89,7 +104,8 @@ def logout(self) -> bool:
print("Error Logout\n", e)
return False
- def _execute_command(self, command, data, multi=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
@@ -105,8 +121,24 @@ def _execute_command(self, command, data, multi=False):
try:
if self.token is None:
raise ValueError("Login first")
- response = Request.post(self.url, data=data, params=params)
- return response.json()
+ 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/resthandle.py b/reolinkapi/handlers/rest_handler.py
similarity index 50%
rename from resthandle.py
rename to reolinkapi/handlers/rest_handler.py
index d2f98b7..a9512f4 100644
--- a/resthandle.py
+++ b/reolinkapi/handlers/rest_handler.py
@@ -1,13 +1,21 @@
-import json
-
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, params=None) -> requests.Response or None:
+ def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \
+ Optional[requests.Response]:
"""
Post request
:param params:
@@ -17,11 +25,8 @@ def post(url: str, data, params=None) -> requests.Response or None:
"""
try:
headers = {'content-type': 'application/json'}
- r = requests.post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies)
- # if params is not None:
- # r = requests.post(url, params=params, json=data, headers=headers, proxies=proxies)
- # else:
- # r = requests.post(url, json=data)
+ r = Request.__getSession().post(url, verify=False, params=params, json=data, headers=headers,
+ proxies=Request.proxies)
if r.status_code == 200:
return r
else:
@@ -31,7 +36,7 @@ def post(url: str, data, params=None) -> requests.Response or None:
raise
@staticmethod
- def get(url, params, timeout=1) -> json or None:
+ def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> Optional[requests.Response]:
"""
Get request
:param url:
@@ -40,8 +45,7 @@ def get(url, params, timeout=1) -> json or None:
:return:
"""
try:
- data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies)
-
+ data = Request.__getSession().get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies)
return data
except Exception as e:
print("Get Error\n", e)
diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/alarm.py b/reolinkapi/mixins/alarm.py
similarity index 85%
rename from api/alarm.py
rename to reolinkapi/mixins/alarm.py
index 2f48efb..53bc6ee 100644
--- a/api/alarm.py
+++ b/reolinkapi/mixins/alarm.py
@@ -1,7 +1,10 @@
+from typing import Dict
+
+
class AlarmAPIMixin:
"""API calls for getting device alarm information."""
- def get_alarm_motion(self) -> object:
+ def get_alarm_motion(self) -> Dict:
"""
Gets the device alarm motion
See examples/response/GetAlarmMotion.json for example response data.
diff --git a/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/api/image.py b/reolinkapi/mixins/image.py
similarity index 66%
rename from api/image.py
rename to reolinkapi/mixins/image.py
index 6cdb823..8e30568 100644
--- a/api/image.py
+++ b/reolinkapi/mixins/image.py
@@ -1,24 +1,26 @@
+from typing import Dict
+
class ImageAPIMixin:
"""API calls for image settings."""
def set_adv_image_settings(self,
- anti_flicker='Outdoor',
- exposure='Auto',
- gain_min=1,
- gain_max=62,
- shutter_min=1,
- shutter_max=125,
- blue_gain=128,
- red_gain=128,
- white_balance='Auto',
- day_night='Auto',
- back_light='DynamicRangeControl',
- blc=128,
- drc=128,
- rotation=0,
- mirroring=0,
- nr3d=1) -> object:
+ anti_flicker: str = 'Outdoor',
+ exposure: str = 'Auto',
+ gain_min: 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.
@@ -66,11 +68,11 @@ def set_adv_image_settings(self,
return self._execute_command('SetIsp', body)
def set_image_settings(self,
- brightness=128,
- contrast=62,
- hue=1,
- saturation=125,
- sharpness=128) -> object:
+ brightness: float = 128,
+ contrast: float = 62,
+ hue: float = 1,
+ saturation: float = 125,
+ sharpness: float = 128) -> Dict:
"""
Sets the camera image settings.
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/api/network.py b/reolinkapi/mixins/network.py
similarity index 59%
rename from api/network.py
rename to reolinkapi/mixins/network.py
index 39af7b8..ae44ba4 100644
--- a/api/network.py
+++ b/reolinkapi/mixins/network.py
@@ -1,7 +1,46 @@
+from typing import Dict
+
+
class NetworkAPIMixin:
"""API calls for network settings."""
- def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port=8000, rtmp_port=1935,
- rtsp_port=554) -> bool:
+ def set_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
@@ -25,7 +64,7 @@ def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port
print("Successfully Set Network Ports")
return True
- def set_wifi(self, ssid, password) -> object:
+ def set_wifi(self, ssid: str, password: str) -> Dict:
body = [{"cmd": "SetWifi", "action": 0, "param": {
"Wifi": {
"ssid": ssid,
@@ -33,7 +72,28 @@ def set_wifi(self, ssid, password) -> object:
}}}]
return self._execute_command('SetWifi', body)
- def get_net_ports(self) -> object:
+ 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.
@@ -44,15 +104,15 @@ def get_net_ports(self) -> object:
{"cmd": "GetP2p", "action": 0, "param": {}}]
return self._execute_command('GetNetPort', body, multi=True)
- def get_wifi(self):
+ def get_wifi(self) -> Dict:
body = [{"cmd": "GetWifi", "action": 1, "param": {}}]
return self._execute_command('GetWifi', body)
- def scan_wifi(self):
+ def scan_wifi(self) -> Dict:
body = [{"cmd": "ScanWifi", "action": 1, "param": {}}]
return self._execute_command('ScanWifi', body)
- def get_network_general(self) -> object:
+ def get_network_general(self) -> Dict:
"""
Get the camera information
See examples/response/GetNetworkGeneral.json for example response data.
@@ -61,7 +121,7 @@ def get_network_general(self) -> object:
body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}]
return self._execute_command('GetLocalLink', body)
- def get_network_ddns(self) -> object:
+ def get_network_ddns(self) -> Dict:
"""
Get the camera DDNS network information
See examples/response/GetNetworkDDNS.json for example response data.
@@ -70,7 +130,7 @@ def get_network_ddns(self) -> object:
body = [{"cmd": "GetDdns", "action": 0, "param": {}}]
return self._execute_command('GetDdns', body)
- def get_network_ntp(self) -> object:
+ def get_network_ntp(self) -> Dict:
"""
Get the camera NTP network information
See examples/response/GetNetworkNTP.json for example response data.
@@ -79,7 +139,7 @@ def get_network_ntp(self) -> object:
body = [{"cmd": "GetNtp", "action": 0, "param": {}}]
return self._execute_command('GetNtp', body)
- def get_network_email(self) -> object:
+ def get_network_email(self) -> Dict:
"""
Get the camera email network information
See examples/response/GetNetworkEmail.json for example response data.
@@ -88,7 +148,7 @@ def get_network_email(self) -> object:
body = [{"cmd": "GetEmail", "action": 0, "param": {}}]
return self._execute_command('GetEmail', body)
- def get_network_ftp(self) -> object:
+ def get_network_ftp(self) -> Dict:
"""
Get the camera FTP network information
See examples/response/GetNetworkFtp.json for example response data.
@@ -97,7 +157,7 @@ def get_network_ftp(self) -> object:
body = [{"cmd": "GetFtp", "action": 0, "param": {}}]
return self._execute_command('GetFtp', body)
- def get_network_push(self) -> object:
+ def get_network_push(self) -> Dict:
"""
Get the camera push network information
See examples/response/GetNetworkPush.json for example response data.
@@ -106,7 +166,7 @@ def get_network_push(self) -> object:
body = [{"cmd": "GetPush", "action": 0, "param": {}}]
return self._execute_command('GetPush', body)
- def get_network_status(self) -> object:
+ def get_network_status(self) -> Dict:
"""
Get the camera status network information
See examples/response/GetNetworkGeneral.json for example response data.
diff --git a/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/api/system.py b/reolinkapi/mixins/system.py
similarity index 83%
rename from api/system.py
rename to reolinkapi/mixins/system.py
index 0eadc6a..dcb590a 100644
--- a/api/system.py
+++ b/reolinkapi/mixins/system.py
@@ -1,11 +1,14 @@
+from typing import Dict
+
+
class SystemAPIMixin:
"""API for accessing general system information of the camera."""
- def get_general_system(self) -> object:
+ def get_general_system(self) -> Dict:
""":return: response json"""
body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}]
return self._execute_command('get_general_system', body, multi=True)
- def get_performance(self) -> object:
+ def get_performance(self) -> Dict:
"""
Get a snapshot of the current performance of the camera.
See examples/response/GetPerformance.json for example response data.
@@ -14,7 +17,7 @@ def get_performance(self) -> object:
body = [{"cmd": "GetPerformance", "action": 0, "param": {}}]
return self._execute_command('GetPerformance', body)
- def get_information(self) -> object:
+ def get_information(self) -> Dict:
"""
Get the camera information
See examples/response/GetDevInfo.json for example response data.
@@ -23,7 +26,7 @@ def get_information(self) -> object:
body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}]
return self._execute_command('GetDevInfo', body)
- def reboot_camera(self) -> object:
+ def reboot_camera(self) -> Dict:
"""
Reboots the camera
:return: response json
@@ -31,11 +34,11 @@ def reboot_camera(self) -> object:
body = [{"cmd": "Reboot", "action": 0, "param": {}}]
return self._execute_command('Reboot', body)
- def get_dst(self) -> object:
+ def get_dst(self) -> Dict:
"""
Get the camera DST information
See examples/response/GetDSTInfo.json for example response data.
:return: response json
"""
body = [{"cmd": "GetTime", "action": 0, "param": {}}]
- return self._execute_command('GetTime', body)
\ No newline at end of file
+ return self._execute_command('GetTime', body)
diff --git a/api/user.py b/reolinkapi/mixins/user.py
similarity index 89%
rename from api/user.py
rename to reolinkapi/mixins/user.py
index 9d430f6..c382c2d 100644
--- a/api/user.py
+++ b/reolinkapi/mixins/user.py
@@ -1,6 +1,9 @@
+from typing import Dict
+
+
class UserAPIMixin:
"""User-related API calls."""
- def get_online_user(self) -> object:
+ def get_online_user(self) -> Dict:
"""
Return a list of current logged-in users in json format
See examples/response/GetOnline.json for example response data.
@@ -9,7 +12,7 @@ def get_online_user(self) -> object:
body = [{"cmd": "GetOnline", "action": 1, "param": {}}]
return self._execute_command('GetOnline', body)
- def get_users(self) -> object:
+ def get_users(self) -> Dict:
"""
Return a list of user accounts from the camera in json format.
See examples/response/GetUser.json for example response data.
@@ -45,7 +48,7 @@ def modify_user(self, username: str, password: str) -> bool:
r_data = self._execute_command('ModifyUser', body)[0]
if r_data["value"]["rspCode"] == 200:
return True
- print("Could not modify user:", username, "\nCamera responded with:", r_data["value"])
+ print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}")
return False
def delete_user(self, username: str) -> bool:
@@ -58,5 +61,5 @@ def delete_user(self, username: str) -> bool:
r_data = self._execute_command('DelUser', body)[0]
if r_data["value"]["rspCode"] == 200:
return True
- print("Could not delete user:", username, "\nCamera responded with:", r_data["value"])
+ print(f"Could not delete user: {username}\nCamera responded with: {r_data['value']}")
return False
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
index 30b468d..945c9b4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1 @@
-requests
-opencv-python
-numpy
-socks
\ No newline at end of file
+.
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 98eba70..b36aaf8 100644
--- a/setup.py
+++ b/setup.py
@@ -1,31 +1,8 @@
#!/usr/bin/python3
-
import os
import re
import codecs
-from setuptools import setup
-
-# Package meta-data.
-NAME = 'reolink-api'
-DESCRIPTION = 'Reolink Camera API written in Python 3.6'
-URL = 'https://github.com/Benehiko/ReolinkCameraAPI'
-AUTHOR_EMAIL = ''
-AUTHOR = 'Benehiko'
-LICENSE = 'GPL-3.0'
-INSTALL_REQUIRES = [
- 'pillow',
- 'pyyaml',
- 'requests>=2.18.4',
- 'numpy',
- 'opencv-python',
- 'pysocks'
-]
-
-
-here = os.path.abspath(os.path.dirname(__file__))
-# read the contents of your README file
-with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
- long_description = f.read()
+from setuptools import setup, find_packages
def read(*parts):
@@ -41,32 +18,46 @@ def find_version(*file_paths):
raise RuntimeError("Unable to find version string.")
-setup(name=NAME,
- python_requires='>=3.6.0',
- version=find_version('api', '__init__.py'),
- description=DESCRIPTION,
- long_description=long_description,
- long_description_content_type='text/markdown',
- author=AUTHOR,
- author_email=AUTHOR_EMAIL,
- url=URL,
- license=LICENSE,
- install_requires=INSTALL_REQUIRES,
- py_modules=[
- 'Camera',
- 'ConfigHandler',
- 'RtspClient',
- 'resthandle',
- 'api.APIHandler',
- 'api.device',
- 'api.display',
- 'api.network',
- 'api.ptz',
- 'api.recording',
- 'api.system',
- 'api.user',
- 'api.zoom',
- 'api.alarm',
- 'api.image'
- ]
- )
+# 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 796f5a2..0000000
--- a/test.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from Camera import Camera
-
-c = Camera("192.168.1.112", "admin", "jUa2kUzi")
-# print("Getting information", c.get_information())
-c.open_video_stream()
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()