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/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 862cb9d..87a205f 100644 --- a/README.md +++ b/README.md @@ -108,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 @@ -134,7 +135,7 @@ SET: - [X] User -> Add User - [X] User -> Manage User - [X] Device -> HDD/SD Card (Format) -- [x] PTZ +- [x] PTZ (including calibrate) - [x] Zoom - [x] Focus - [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) @@ -151,5 +152,8 @@ do not work and is not supported here. - RLC-410-5MP - RLC-510A - RLC-520 +- RLC-823A - C1-Pro - D400 +- E1 Zoom + diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 0ba744c..1fa5bdd 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -9,3 +9,4 @@ dst = cam.get_dst() ok = cam.add_user("foo", "bar", "admin") alarm = cam.get_alarm_motion() + cam.set_device_name(name='my_camera') \ No newline at end of file diff --git a/examples/custom_ssl_session.py b/examples/custom_ssl_session.py new file mode 100644 index 0000000..bd13f5e --- /dev/null +++ b/examples/custom_ssl_session.py @@ -0,0 +1,30 @@ +from reolinkapi import Camera + +import urllib3 +import requests +from urllib3.util import create_urllib3_context + +class CustomSSLContextHTTPAdapter(requests.adapters.HTTPAdapter): + def __init__(self, ssl_context=None, **kwargs): + self.ssl_context = ssl_context + super().__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = urllib3.poolmanager.PoolManager( + num_pools=connections, maxsize=maxsize, + block=block, ssl_context=self.ssl_context) + +urllib3.disable_warnings() +ctx = create_urllib3_context() +ctx.load_default_certs() +ctx.set_ciphers("AES128-GCM-SHA256") +ctx.check_hostname = False + +session = requests.session() +session.adapters.pop("https://", None) +session.mount("https://", CustomSSLContextHTTPAdapter(ctx)) + +## Add a custom http handler to add in different ciphers that may +## not be aloud by default in openssl which urlib uses +cam = Camera("url", "user", "password", https=True, session=session) +cam.reboot_camera() diff --git a/examples/download_motions.py b/examples/download_motions.py index 59ec181..809571f 100644 --- a/examples/download_motions.py +++ b/examples/download_motions.py @@ -23,22 +23,32 @@ def read_config(props_path: str) -> dict: # Read in your ip, username, & password # (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure) -config = read_config('../secrets.cfg') +config = read_config('camera.cfg') ip = config.get('camera', 'ip') un = config.get('camera', 'username') pw = config.get('camera', 'password') # Connect to camera -cam = Camera(ip, un, pw) +cam = Camera(ip, un, pw, https=True) -start = (dt.now() - timedelta(hours=1)) +start = dt.combine(dt.now(), dt.min.time()) end = dt.now() # Collect motion events between these timestamps for substream -processed_motions = cam.get_motion_files(start=start, end=end, streamtype='sub') +processed_motions = cam.get_motion_files(start=start, end=end, streamtype='main', channel=0) +processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) -dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads') +start = dt.now() - timedelta(days=1) +end = dt.combine(start, dt.max.time()) +processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + + +output_files = [] for i, motion in enumerate(processed_motions): fname = motion['filename'] # Download the mp4 - resp = cam.get_file(fname, output_path=os.path.join(dl_dir, f'motion_event_{i}.mp4')) + print("Getting %s" % (fname)) + output_path = os.path.join('/tmp/', fname.replace('/','_')) + output_files += output_path + if not os.path.isfile(output_path): + resp = cam.get_file(fname, output_path=output_path) diff --git a/examples/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/GetPtzCheckState.json b/examples/response/GetPtzCheckState.json new file mode 100644 index 0000000..55386ec --- /dev/null +++ b/examples/response/GetPtzCheckState.json @@ -0,0 +1,19 @@ +[ + { + "cmd": "GetPtzCheckState", + "code": 0, + "initial": { + "PtzCheckState": 2 + }, + "range": { + "PtzCheckState": [ + 0, + 1, + 2 + ] + }, + "value": { + "PtzCheckState": 2 + } + } +] diff --git a/examples/response/GetPtzPresets.json b/examples/response/GetPtzPresets.json new file mode 100644 index 0000000..b89dad3 --- /dev/null +++ b/examples/response/GetPtzPresets.json @@ -0,0 +1,926 @@ +[ + { + "cmd": "GetPtzPreset", + "code": 0, + "initial": { + "PtzPreset": [ + { + "channel": 0, + "enable": 1, + "id": 0, + "imgName": "preset_00", + "name": "namepos0" + }, + { + "channel": 0, + "enable": 1, + "id": 1, + "imgName": "preset_01", + "name": "othernamepos1" + }, + { + "channel": 0, + "enable": 0, + "id": 2, + "imgName": "", + "name": "pos3" + }, + { + "channel": 0, + "enable": 0, + "id": 3, + "imgName": "", + "name": "pos4" + }, + { + "channel": 0, + "enable": 0, + "id": 4, + "imgName": "", + "name": "pos5" + }, + { + "channel": 0, + "enable": 0, + "id": 5, + "imgName": "", + "name": "pos6" + }, + { + "channel": 0, + "enable": 0, + "id": 6, + "imgName": "", + "name": "pos7" + }, + { + "channel": 0, + "enable": 0, + "id": 7, + "imgName": "", + "name": "pos8" + }, + { + "channel": 0, + "enable": 0, + "id": 8, + "imgName": "", + "name": "pos9" + }, + { + "channel": 0, + "enable": 0, + "id": 9, + "imgName": "", + "name": "pos10" + }, + { + "channel": 0, + "enable": 0, + "id": 10, + "imgName": "", + "name": "pos11" + }, + { + "channel": 0, + "enable": 0, + "id": 11, + "imgName": "", + "name": "pos12" + }, + { + "channel": 0, + "enable": 0, + "id": 12, + "imgName": "", + "name": "pos13" + }, + { + "channel": 0, + "enable": 0, + "id": 13, + "imgName": "", + "name": "pos14" + }, + { + "channel": 0, + "enable": 0, + "id": 14, + "imgName": "", + "name": "pos15" + }, + { + "channel": 0, + "enable": 0, + "id": 15, + "imgName": "", + "name": "pos16" + }, + { + "channel": 0, + "enable": 0, + "id": 16, + "imgName": "", + "name": "pos17" + }, + { + "channel": 0, + "enable": 0, + "id": 17, + "imgName": "", + "name": "pos18" + }, + { + "channel": 0, + "enable": 0, + "id": 18, + "imgName": "", + "name": "pos19" + }, + { + "channel": 0, + "enable": 0, + "id": 19, + "imgName": "", + "name": "pos20" + }, + { + "channel": 0, + "enable": 0, + "id": 20, + "imgName": "", + "name": "pos21" + }, + { + "channel": 0, + "enable": 0, + "id": 21, + "imgName": "", + "name": "pos22" + }, + { + "channel": 0, + "enable": 0, + "id": 22, + "imgName": "", + "name": "pos23" + }, + { + "channel": 0, + "enable": 0, + "id": 23, + "imgName": "", + "name": "pos24" + }, + { + "channel": 0, + "enable": 0, + "id": 24, + "imgName": "", + "name": "pos25" + }, + { + "channel": 0, + "enable": 0, + "id": 25, + "imgName": "", + "name": "pos26" + }, + { + "channel": 0, + "enable": 0, + "id": 26, + "imgName": "", + "name": "pos27" + }, + { + "channel": 0, + "enable": 0, + "id": 27, + "imgName": "", + "name": "pos28" + }, + { + "channel": 0, + "enable": 0, + "id": 28, + "imgName": "", + "name": "pos29" + }, + { + "channel": 0, + "enable": 0, + "id": 29, + "imgName": "", + "name": "pos30" + }, + { + "channel": 0, + "enable": 0, + "id": 30, + "imgName": "", + "name": "pos31" + }, + { + "channel": 0, + "enable": 0, + "id": 31, + "imgName": "", + "name": "pos32" + }, + { + "channel": 0, + "enable": 0, + "id": 32, + "imgName": "", + "name": "pos33" + }, + { + "channel": 0, + "enable": 0, + "id": 33, + "imgName": "", + "name": "pos34" + }, + { + "channel": 0, + "enable": 0, + "id": 34, + "imgName": "", + "name": "pos35" + }, + { + "channel": 0, + "enable": 0, + "id": 35, + "imgName": "", + "name": "pos36" + }, + { + "channel": 0, + "enable": 0, + "id": 36, + "imgName": "", + "name": "pos37" + }, + { + "channel": 0, + "enable": 0, + "id": 37, + "imgName": "", + "name": "pos38" + }, + { + "channel": 0, + "enable": 0, + "id": 38, + "imgName": "", + "name": "pos39" + }, + { + "channel": 0, + "enable": 0, + "id": 39, + "imgName": "", + "name": "pos40" + }, + { + "channel": 0, + "enable": 0, + "id": 40, + "imgName": "", + "name": "pos41" + }, + { + "channel": 0, + "enable": 0, + "id": 41, + "imgName": "", + "name": "pos42" + }, + { + "channel": 0, + "enable": 0, + "id": 42, + "imgName": "", + "name": "pos43" + }, + { + "channel": 0, + "enable": 0, + "id": 43, + "imgName": "", + "name": "pos44" + }, + { + "channel": 0, + "enable": 0, + "id": 44, + "imgName": "", + "name": "pos45" + }, + { + "channel": 0, + "enable": 0, + "id": 45, + "imgName": "", + "name": "pos46" + }, + { + "channel": 0, + "enable": 0, + "id": 46, + "imgName": "", + "name": "pos47" + }, + { + "channel": 0, + "enable": 0, + "id": 47, + "imgName": "", + "name": "pos48" + }, + { + "channel": 0, + "enable": 0, + "id": 48, + "imgName": "", + "name": "pos49" + }, + { + "channel": 0, + "enable": 0, + "id": 49, + "imgName": "", + "name": "pos50" + }, + { + "channel": 0, + "enable": 0, + "id": 50, + "imgName": "", + "name": "pos51" + }, + { + "channel": 0, + "enable": 0, + "id": 51, + "imgName": "", + "name": "pos52" + }, + { + "channel": 0, + "enable": 0, + "id": 52, + "imgName": "", + "name": "pos53" + }, + { + "channel": 0, + "enable": 0, + "id": 53, + "imgName": "", + "name": "pos54" + }, + { + "channel": 0, + "enable": 0, + "id": 54, + "imgName": "", + "name": "pos55" + }, + { + "channel": 0, + "enable": 0, + "id": 55, + "imgName": "", + "name": "pos56" + }, + { + "channel": 0, + "enable": 0, + "id": 56, + "imgName": "", + "name": "pos57" + }, + { + "channel": 0, + "enable": 0, + "id": 57, + "imgName": "", + "name": "pos58" + }, + { + "channel": 0, + "enable": 0, + "id": 58, + "imgName": "", + "name": "pos59" + }, + { + "channel": 0, + "enable": 0, + "id": 59, + "imgName": "", + "name": "pos60" + }, + { + "channel": 0, + "enable": 0, + "id": 60, + "imgName": "", + "name": "pos61" + }, + { + "channel": 0, + "enable": 0, + "id": 61, + "imgName": "", + "name": "pos62" + }, + { + "channel": 0, + "enable": 0, + "id": 62, + "imgName": "", + "name": "pos63" + }, + { + "channel": 0, + "enable": 0, + "id": 63, + "imgName": "", + "name": "pos64" + } + ] + }, + "range": { + "PtzPreset": { + "channel": 0, + "enable": "boolean", + "id": { + "max": 64, + "min": 1 + }, + "imgName": { + "maxLen": 31 + }, + "name": { + "maxLen": 31 + } + } + }, + "value": { + "PtzPreset": [ + { + "channel": 0, + "enable": 1, + "id": 0, + "imgName": "preset_00", + "name": "0" + }, + { + "channel": 0, + "enable": 1, + "id": 1, + "imgName": "preset_01", + "name": "1" + }, + { + "channel": 0, + "enable": 0, + "id": 2, + "imgName": "", + "name": "pos3" + }, + { + "channel": 0, + "enable": 0, + "id": 3, + "imgName": "", + "name": "pos4" + }, + { + "channel": 0, + "enable": 0, + "id": 4, + "imgName": "", + "name": "pos5" + }, + { + "channel": 0, + "enable": 0, + "id": 5, + "imgName": "", + "name": "pos6" + }, + { + "channel": 0, + "enable": 0, + "id": 6, + "imgName": "", + "name": "pos7" + }, + { + "channel": 0, + "enable": 0, + "id": 7, + "imgName": "", + "name": "pos8" + }, + { + "channel": 0, + "enable": 0, + "id": 8, + "imgName": "", + "name": "pos9" + }, + { + "channel": 0, + "enable": 0, + "id": 9, + "imgName": "", + "name": "pos10" + }, + { + "channel": 0, + "enable": 0, + "id": 10, + "imgName": "", + "name": "pos11" + }, + { + "channel": 0, + "enable": 0, + "id": 11, + "imgName": "", + "name": "pos12" + }, + { + "channel": 0, + "enable": 0, + "id": 12, + "imgName": "", + "name": "pos13" + }, + { + "channel": 0, + "enable": 0, + "id": 13, + "imgName": "", + "name": "pos14" + }, + { + "channel": 0, + "enable": 0, + "id": 14, + "imgName": "", + "name": "pos15" + }, + { + "channel": 0, + "enable": 0, + "id": 15, + "imgName": "", + "name": "pos16" + }, + { + "channel": 0, + "enable": 0, + "id": 16, + "imgName": "", + "name": "pos17" + }, + { + "channel": 0, + "enable": 0, + "id": 17, + "imgName": "", + "name": "pos18" + }, + { + "channel": 0, + "enable": 0, + "id": 18, + "imgName": "", + "name": "pos19" + }, + { + "channel": 0, + "enable": 0, + "id": 19, + "imgName": "", + "name": "pos20" + }, + { + "channel": 0, + "enable": 0, + "id": 20, + "imgName": "", + "name": "pos21" + }, + { + "channel": 0, + "enable": 0, + "id": 21, + "imgName": "", + "name": "pos22" + }, + { + "channel": 0, + "enable": 0, + "id": 22, + "imgName": "", + "name": "pos23" + }, + { + "channel": 0, + "enable": 0, + "id": 23, + "imgName": "", + "name": "pos24" + }, + { + "channel": 0, + "enable": 0, + "id": 24, + "imgName": "", + "name": "pos25" + }, + { + "channel": 0, + "enable": 0, + "id": 25, + "imgName": "", + "name": "pos26" + }, + { + "channel": 0, + "enable": 0, + "id": 26, + "imgName": "", + "name": "pos27" + }, + { + "channel": 0, + "enable": 0, + "id": 27, + "imgName": "", + "name": "pos28" + }, + { + "channel": 0, + "enable": 0, + "id": 28, + "imgName": "", + "name": "pos29" + }, + { + "channel": 0, + "enable": 0, + "id": 29, + "imgName": "", + "name": "pos30" + }, + { + "channel": 0, + "enable": 0, + "id": 30, + "imgName": "", + "name": "pos31" + }, + { + "channel": 0, + "enable": 0, + "id": 31, + "imgName": "", + "name": "pos32" + }, + { + "channel": 0, + "enable": 0, + "id": 32, + "imgName": "", + "name": "pos33" + }, + { + "channel": 0, + "enable": 0, + "id": 33, + "imgName": "", + "name": "pos34" + }, + { + "channel": 0, + "enable": 0, + "id": 34, + "imgName": "", + "name": "pos35" + }, + { + "channel": 0, + "enable": 0, + "id": 35, + "imgName": "", + "name": "pos36" + }, + { + "channel": 0, + "enable": 0, + "id": 36, + "imgName": "", + "name": "pos37" + }, + { + "channel": 0, + "enable": 0, + "id": 37, + "imgName": "", + "name": "pos38" + }, + { + "channel": 0, + "enable": 0, + "id": 38, + "imgName": "", + "name": "pos39" + }, + { + "channel": 0, + "enable": 0, + "id": 39, + "imgName": "", + "name": "pos40" + }, + { + "channel": 0, + "enable": 0, + "id": 40, + "imgName": "", + "name": "pos41" + }, + { + "channel": 0, + "enable": 0, + "id": 41, + "imgName": "", + "name": "pos42" + }, + { + "channel": 0, + "enable": 0, + "id": 42, + "imgName": "", + "name": "pos43" + }, + { + "channel": 0, + "enable": 0, + "id": 43, + "imgName": "", + "name": "pos44" + }, + { + "channel": 0, + "enable": 0, + "id": 44, + "imgName": "", + "name": "pos45" + }, + { + "channel": 0, + "enable": 0, + "id": 45, + "imgName": "", + "name": "pos46" + }, + { + "channel": 0, + "enable": 0, + "id": 46, + "imgName": "", + "name": "pos47" + }, + { + "channel": 0, + "enable": 0, + "id": 47, + "imgName": "", + "name": "pos48" + }, + { + "channel": 0, + "enable": 0, + "id": 48, + "imgName": "", + "name": "pos49" + }, + { + "channel": 0, + "enable": 0, + "id": 49, + "imgName": "", + "name": "pos50" + }, + { + "channel": 0, + "enable": 0, + "id": 50, + "imgName": "", + "name": "pos51" + }, + { + "channel": 0, + "enable": 0, + "id": 51, + "imgName": "", + "name": "pos52" + }, + { + "channel": 0, + "enable": 0, + "id": 52, + "imgName": "", + "name": "pos53" + }, + { + "channel": 0, + "enable": 0, + "id": 53, + "imgName": "", + "name": "pos54" + }, + { + "channel": 0, + "enable": 0, + "id": 54, + "imgName": "", + "name": "pos55" + }, + { + "channel": 0, + "enable": 0, + "id": 55, + "imgName": "", + "name": "pos56" + }, + { + "channel": 0, + "enable": 0, + "id": 56, + "imgName": "", + "name": "pos57" + }, + { + "channel": 0, + "enable": 0, + "id": 57, + "imgName": "", + "name": "pos58" + }, + { + "channel": 0, + "enable": 0, + "id": 58, + "imgName": "", + "name": "pos59" + }, + { + "channel": 0, + "enable": 0, + "id": 59, + "imgName": "", + "name": "pos60" + }, + { + "channel": 0, + "enable": 0, + "id": 60, + "imgName": "", + "name": "pos61" + }, + { + "channel": 0, + "enable": 0, + "id": 61, + "imgName": "", + "name": "pos62" + }, + { + "channel": 0, + "enable": 0, + "id": 62, + "imgName": "", + "name": "pos63" + }, + { + "channel": 0, + "enable": 0, + "id": 63, + "imgName": "", + "name": "pos64" + } + ] + } + } +] diff --git a/examples/response/PtzCheck.json b/examples/response/PtzCheck.json new file mode 100644 index 0000000..8274769 --- /dev/null +++ b/examples/response/PtzCheck.json @@ -0,0 +1 @@ +[{"cmd": "PtzCheck", "code": 0, "value": {"rspCode": 200}}] \ No newline at end of file diff --git a/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/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='http://github.com/store_true', help="Search for main channel instead of sub channel") + parser.add_argument('files', nargs='*', help="Optional video file names to process") + args = parser.parse_args() + + 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/reolinkapi/__init__.py b/reolinkapi/__init__.py index 99bb450..ce35f76 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera -__version__ = "0.1.5" +__version__ = "0.4.1" diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py index 501ceb7..2ef7464 100644 --- a/reolinkapi/handlers/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -14,6 +14,7 @@ from reolinkapi.mixins.system import SystemAPIMixin from reolinkapi.mixins.user import UserAPIMixin from reolinkapi.mixins.zoom import ZoomAPIMixin +from reolinkapi.mixins.nvrdownload import NvrDownloadAPIMixin class APIHandler(AlarmAPIMixin, @@ -28,7 +29,8 @@ class APIHandler(AlarmAPIMixin, SystemAPIMixin, UserAPIMixin, ZoomAPIMixin, - StreamAPIMixin): + StreamAPIMixin, + NvrDownloadAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. @@ -56,6 +58,7 @@ def __init__(self, ip: str, username: str, password: str, https: bool = False, * self.username = username self.password = password Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found + Request.session = kwargs.get("session") # Defaults to None if key isn't found def login(self) -> bool: """ @@ -118,7 +121,7 @@ def _execute_command(self, command: str, data: List[Dict], multi: bool = False) try: if self.token is None: raise ValueError("Login first") - if command == 'Download': + if command == 'Download' or command == 'Playback': # Special handling for downloading an mp4 # Pop the filepath from data tgt_filepath = data[0].pop('filepath') diff --git a/reolinkapi/handlers/rest_handler.py b/reolinkapi/handlers/rest_handler.py index 66f172a..a9512f4 100644 --- a/reolinkapi/handlers/rest_handler.py +++ b/reolinkapi/handlers/rest_handler.py @@ -4,6 +4,14 @@ class Request: proxies = None + session = None + + @staticmethod + def __getSession(): + reqHandler = requests + if Request.session is not None: + reqHandler = Request.session + return reqHandler @staticmethod def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \ @@ -17,7 +25,7 @@ def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None """ try: headers = {'content-type': 'application/json'} - r = requests.post(url, verify=False, params=params, json=data, headers=headers, + r = Request.__getSession().post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies) if r.status_code == 200: return r @@ -37,7 +45,7 @@ def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> O :return: """ try: - data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) + data = Request.__getSession().get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) return data except Exception as e: print("Get Error\n", e) diff --git a/reolinkapi/mixins/device.py b/reolinkapi/mixins/device.py index 684be45..21b7961 100644 --- a/reolinkapi/mixins/device.py +++ b/reolinkapi/mixins/device.py @@ -5,6 +5,25 @@ class DeviceAPIMixin: """API calls for getting device information.""" DEFAULT_HDD_ID = [0] + def set_device_name(self, name: str) -> bool: + """ + Set the device name of the camera. + :param name: The new name for the device + :return: bool indicating success + """ + body = [{"cmd": "SetDevName", "action": 0, "param": {"DevName": {"name": name}}}] + self._execute_command('SetDevName', body) + print(f"Successfully set device name to: {name}") + return True + + def get_device_name(self) -> Dict: + """ + Get the device name of the camera. + :return: Dict containing the device name + """ + body = [{"cmd": "GetDevName", "action": 0, "param": {}}] + return self._execute_command('GetDevName', body) + def get_hdd_info(self) -> Dict: """ Gets all HDD and SD card information from Camera diff --git a/reolinkapi/mixins/download.py b/reolinkapi/mixins/download.py index ebd1603..3580930 100644 --- a/reolinkapi/mixins/download.py +++ b/reolinkapi/mixins/download.py @@ -1,18 +1,21 @@ class DownloadAPIMixin: """API calls for downloading video files.""" - def get_file(self, filename: str, output_path: str) -> bool: + def get_file(self, filename: str, output_path: str, method = 'Playback') -> bool: """ Download the selected video file + On at least Trackmix Wifi, it was observed that the Playback method + yields much improved download speeds over the Download method, for + unknown reasons. :return: response json """ body = [ { - "cmd": "Download", + "cmd": method, "source": filename, "output": filename, "filepath": output_path } ] - resp = self._execute_command('Download', body) + resp = self._execute_command(method, body) return resp diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py index 73d3437..6aada63 100644 --- a/reolinkapi/mixins/motion.py +++ b/reolinkapi/mixins/motion.py @@ -10,7 +10,7 @@ class MotionAPIMixin: """API calls for past motion alerts.""" def get_motion_files(self, start: dt, end: dt = dt.now(), - streamtype: str = 'sub') -> PROCESSED_MOTION_LIST_TYPE: + streamtype: str = 'sub', channel = 0) -> PROCESSED_MOTION_LIST_TYPE: """ Get the timestamps and filenames of motion detection events for the time range provided. @@ -22,7 +22,7 @@ def get_motion_files(self, start: dt, end: dt = dt.now(), """ search_params = { 'Search': { - 'channel': 0, + 'channel': channel, 'streamType': streamtype, 'onlyStatus': 0, 'StartTime': { diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py index f4fe4a6..ae44ba4 100644 --- a/reolinkapi/mixins/network.py +++ b/reolinkapi/mixins/network.py @@ -3,6 +3,42 @@ class NetworkAPIMixin: """API calls for network settings.""" + def set_network_settings(self, ip: str, gateway: str, mask: str, dns1: str, dns2: str, mac: str, + use_dhcp: bool = True, auto_dns: bool = True) -> Dict: + """ + Set network settings including IP, gateway, subnet mask, DNS, and connection type (DHCP or Static). + + :param ip: str + :param gateway: str + :param mask: str + :param dns1: str + :param dns2: str + :param mac: str + :param use_dhcp: bool + :param auto_dns: bool + :return: Dict + """ + body = [{"cmd": "SetLocalLink", "action": 0, "param": { + "LocalLink": { + "dns": { + "auto": 1 if auto_dns else 0, + "dns1": dns1, + "dns2": dns2 + }, + "mac": mac, + "static": { + "gateway": gateway, + "ip": ip, + "mask": mask + }, + "type": "DHCP" if use_dhcp else "Static" + } + }}] + + return self._execute_command('SetLocalLink', body) + print("Successfully Set Network Settings") + return True + def set_net_port(self, http_port: float = 80, https_port: float = 443, media_port: float = 9000, onvif_port: float = 8000, rtmp_port: float = 1935, rtsp_port: float = 554) -> bool: """ @@ -36,6 +72,27 @@ def set_wifi(self, ssid: str, password: str) -> Dict: }}}] return self._execute_command('SetWifi', body) + def set_ntp(self, enable: bool = True, interval: int = 1440, port: int = 123, server: str = "pool.ntp.org") -> Dict: + """ + Set NTP settings. + + :param enable: bool + :param interval: int + :param port: int + :param server: str + :return: Dict + """ + body = [{"cmd": "SetNtp", "action": 0, "param": { + "Ntp": { + "enable": int(enable), + "interval": interval, + "port": port, + "server": server + }}}] + response = self._execute_command('SetNtp', body) + print("Successfully Set NTP Settings") + return response + def get_net_ports(self) -> Dict: """ Get network ports diff --git a/reolinkapi/mixins/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 index 80841a0..fd11dfc 100644 --- a/reolinkapi/mixins/ptz.py +++ b/reolinkapi/mixins/ptz.py @@ -5,6 +5,39 @@ class PtzAPIMixin: """ API for PTZ functions. """ + def get_ptz_check_state(self) -> Dict: + """ + Get PTZ Check State Information that indicates whether calibration is required (0) running (1) or done (2) + Value is contained in response[0]["value"]["PtzCheckState"]. + See examples/response/GetPtzCheckState.json for example response data. + :return: response json + """ + body = [{"cmd": "GetPtzCheckState", "action": 1, "param": { "channel": 0}}] + return self._execute_command('GetPtzCheckState', body) + + def get_ptz_presets(self) -> Dict: + """ + Get ptz presets + See examples/response/GetPtzPresets.json for example response data. + :return: response json + """ + + body = [{"cmd": "GetPtzPreset", "action": 1, "param": { "channel": 0}}] + return self._execute_command('GetPtzPreset', body) + + def perform_calibration(self) -> Dict: + """ + Do the calibration (like app -> ptz -> three dots -> calibration). Moves camera to all end positions. + If not calibrated, your viewpoint of presets might drift. So before setting new presets, or moving to preset, + check calibration status (get_ptz_check_state -> 2 = calibrated) and perform calibration if not yet calibrated. + As of 2024-01-23 (most recent firmware 3.1.0.1711_23010700 for E1 Zoom) does not do this on startup. + Method blocks while calibrating. + See examples/response/PtzCheck.json for example response data. + :return: response json + """ + data = [{"cmd": "PtzCheck", "action": 0, "param": {"channel": 0}}] + return self._execute_command('PtzCheck', data) + def _send_operation(self, operation: str, speed: float, index: float = None) -> Dict: # Refactored to reduce redundancy param = {"channel": 0, "op": operation, "speed": speed} @@ -18,8 +51,8 @@ def _send_noparm_operation(self, operation: str) -> Dict: return self._execute_command('PtzCtrl', data) def _send_set_preset(self, enable: float, preset: float = 1, name: str = 'pos1') -> Dict: - data = [{"cmd": "SetPtzPreset", "action": 0, "param": { - "channel": 0, "enable": enable, "id": preset, "name": name}}] + data = [{"cmd": "SetPtzPreset", "action": 0, "param": { "PtzPreset": { + "channel": 0, "enable": enable, "id": preset, "name": name}}}] return self._execute_command('PtzCtrl', data) def go_to_preset(self, speed: float = 60, index: float = 1) -> Dict: diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 6798a42..76dc239 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -40,7 +40,7 @@ def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: 'user': self.username, 'password': self.password, } - parms = parse.urlencode(data).encode("utf-8") + parms = parse.urlencode(data, safe="!").encode("utf-8") try: response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout) @@ -52,12 +52,16 @@ def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: except Exception as e: print("Could not get Image data\n", e) raise -except ImportError: +except ImportError as err: + print("ImportError", err) + class StreamAPIMixin: """ API calls for opening a video stream or capturing an image from the camera.""" def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any: - raise ImportError('''open_video_stream requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"''') + raise ImportError(f'open_video_stream requires streaming extra dependencies\nFor instance "pip install ' + f'reolinkapi[streaming]"') def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional['Image']: - raise ImportError('''open_video_stream requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"''') + raise ImportError( + f'get_snap requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"') diff --git a/reolinkapi/mixins/zoom.py b/reolinkapi/mixins/zoom.py index 0f5778d..74549e0 100644 --- a/reolinkapi/mixins/zoom.py +++ b/reolinkapi/mixins/zoom.py @@ -16,6 +16,31 @@ def _stop_zooming_or_focusing(self) -> Dict: data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}] return self._execute_command('PtzCtrl', data) + def get_zoom_focus(self) -> Dict: + """This command returns the current zoom and focus values.""" + data = [{"cmd": "GetZoomFocus", "action": 0, "param": {"channel": 0}}] + return self._execute_command('GetZoomFocus', data) + + def start_zoom_pos(self, position: float) -> Dict: + """This command sets the zoom position.""" + data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "ZoomPos", "pos": position}}}] + return self._execute_command('StartZoomFocus', data) + + def start_focus_pos(self, position: float) -> Dict: + """This command sets the focus position.""" + data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "FocusPos", "pos": position}}}] + return self._execute_command('StartZoomFocus', data) + + def get_auto_focus(self) -> Dict: + """This command returns the current auto focus status.""" + data = [{"cmd": "GetAutoFocus", "action": 0, "param": {"channel": 0}}] + return self._execute_command('GetAutoFocus', data) + + def set_auto_focus(self, disable: bool) -> Dict: + """This command sets the auto focus status.""" + data = [{"cmd": "SetAutoFocus", "action": 0, "param": {"AutoFocus": {"channel": 0, "disable": 1 if disable else 0}}}] + return self._execute_command('SetAutoFocus', data) + def start_zooming_in(self, speed: float = 60) -> Dict: """ The camera zooms in until self.stop_zooming() is called. diff --git a/setup.py b/setup.py index 8166180..b36aaf8 100644 --- a/setup.py +++ b/setup.py @@ -26,15 +26,16 @@ def find_version(*file_paths): AUTHOR = 'Benehiko' LICENSE = 'GPL-3.0' INSTALL_REQUIRES = [ + 'setuptools', 'PySocks==1.7.1', - 'PyYaml==5.3.1', - 'requests>=2.18.4', + 'PyYaml==6.0.2', + 'requests>=2.32.3', ] EXTRAS_REQUIRE = { 'streaming': [ - 'numpy==1.19.4', - 'opencv-python==4.4.0.46', - 'Pillow==8.0.1', + 'numpy==2.0.1', + 'opencv-python==4.10.0.84', + 'Pillow==10.4.0', ], } @@ -47,7 +48,7 @@ def find_version(*file_paths): setup( name=NAME, - python_requires='>=3.6.0', + python_requires='>=3.12.4', version=find_version('reolinkapi', '__init__.py'), description=DESCRIPTION, long_description=long_description, diff --git a/tests/test_camera.py b/tests/test_camera.py index 67851d0..c5cdf98 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -35,6 +35,12 @@ def test_camera(self): self.assertTrue(self.cam.ip == self.config.get('camera', 'ip')) self.assertTrue(self.cam.token != '') + def test_snapshot(self): + img = self.cam.get_snap() + # write Pillow Image to file + img.save('./tmp/snaps/camera.jpg') + self.assertTrue(os.path.exists('./tmp/snaps/camera.jpg')) + if __name__ == '__main__': unittest.main()