diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml new file mode 100644 index 0000000..a3e903c --- /dev/null +++ b/.github/workflows/python-package.yaml @@ -0,0 +1,43 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest build + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Build + run: | + python -m build + # - name: Test with pytest + # run: | + # pytest diff --git a/.gitignore b/.gitignore index a65d046..c0c4d43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +secrets.cfg # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -56,3 +57,6 @@ docs/_build/ # PyBuilder target/ + +.idea/ +venv/ \ No newline at end of file diff --git a/APIHandler.py b/APIHandler.py deleted file mode 100644 index 2653081..0000000 --- a/APIHandler.py +++ /dev/null @@ -1,178 +0,0 @@ -import json - -from resthandle import Request - - -class APIHandler: - - def __init__(self, ip): - self.url = "http://" + ip + "/cgi-bin/api.cgi" - self.token = None - - # Token - - def login(self, username: str, password: str): - """ - Get login token - Must be called first, before any other operation can be performed - :param username: - :param password: - :return: - """ - try: - body = [{"cmd": "Login", "action": 0, "param": {"User": {"userName": username, "password": password}}}] - param = {"cmd": "Login", "token": "null"} - response = Request.post(self.url, data=body, params=param) - if response is not None: - data = json.loads(response.text)[0] - code = data["code"] - if int(code) == 0: - self.token = data["value"]["Token"]["name"] - print("Login success") - print(self.token) - else: - print("Failed to login\nStatus Code:", response.status_code) - except Exception as e: - print("Error Login\n", e) - raise - - ########### - # NETWORK - ########### - - ########### - # SET Network - ########### - def set_net_port(self, httpPort=80, httpsPort=443, mediaPort=9000, onvifPort=8000, rtmpPort=1935, rtspPort=554): - """ - Set network ports - If nothing is specified, the default values will be used - :param httpPort: - :param httpsPort: - :param mediaPort: - :param onvifPort: - :param rtmpPort: - :param rtspPort: - :return: - """ - try: - if self.token is None: - raise ValueError("Login first") - - body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": { - "httpPort": httpPort, - "httpsPort": httpsPort, - "mediaPort": mediaPort, - "onvifPort": onvifPort, - "rtmpPort": rtmpPort, - "rtspPort": rtspPort - }}}] - param = {"token": self.token} - response = Request.post(self.url, data=body, params=param) - if response is not None: - if response.status_code == 200: - print("Successfully Set Network Ports") - else: - print("Something went wront\nStatus Code:", response.status_code) - - except Exception as e: - print("Setting Network Port Error\n", e) - raise - - def set_wifi(self, ssid, password): - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "SetWifi", "action": 0, "param": { - "Wifi": { - "ssid": ssid, - "password": password - }}}] - param = {"cmd": "SetWifi", "token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Could not Set Wifi details", e) - raise - - ########### - # GET - ########### - def get_net_ports(self): - """ - Get network ports - :return: - """ - try: - if self.token is not None: - raise ValueError("Login first") - - body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, - {"cmd": "GetUpnp", "action": 0, "param": {}}, - {"cmd": "GetP2p", "action": 0, "param": {}}] - param = {"token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Get Network Ports", e) - - def get_link_local(self): - """ - Get General network data - This includes IP address, Device mac, Gateway and DNS - :return: - """ - try: - if self.token is None: - raise ValueError("Login first") - - body = [{"cmd": "GetLocalLink", "action": 1, "param": {}}] - param = {"cmd": "GetLocalLink", "token": self.token} - request = Request.post(self.url, data=body, params=param) - return json.loads(request.text) - except Exception as e: - print("Could not get Link Local", e) - raise - - def get_wifi(self): - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "GetWifi", "action": 1, "param": {}}] - param = {"cmd": "GetWifi", "token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Could not get Wifi\n", e) - raise - - def scan_wifi(self): - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] - param = {"cmd": "ScanWifi", "token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Could not Scan wifi\n", e) - raise - - ########### - # SYSTEM - ########### - - ########### - # GET - ########### - def get_general_system(self): - try: - if self.token is None: - raise ValueError("Login first") - body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}] - param = {"token": self.token} - response = Request.post(self.url, data=body, params=param) - return json.loads(response.text) - except Exception as e: - print("Could not get General System settings\n", e) - raise diff --git a/Camera.py b/Camera.py deleted file mode 100644 index 1e82361..0000000 --- a/Camera.py +++ /dev/null @@ -1,11 +0,0 @@ -from APIHandler import APIHandler - - -class Camera(APIHandler): - - def __init__(self, ip, username="admin", password=""): - APIHandler.__init__(self, ip) - self.ip = ip - self.username = username - self.password = password - super().login(self.username, self.password) diff --git a/ConfigHandler.py b/ConfigHandler.py deleted file mode 100644 index 67e8d62..0000000 --- a/ConfigHandler.py +++ /dev/null @@ -1,17 +0,0 @@ -import io - -import yaml - - -class ConfigHandler: - camera_settings = {} - - @staticmethod - def load() -> yaml or None: - try: - stream = io.open("config.yml", 'r', encoding='utf8') - data = yaml.safe_load(stream) - return data - except Exception as e: - print("Config Property Error\n", e) - return None diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..defbd59 --- /dev/null +++ b/Pipfile @@ -0,0 +1,16 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +pillow = "*" +pyyaml = "*" +requests = "*" +numpy = "*" +opencv-python = "*" +pysocks = "*" + +[requires] diff --git a/README.md b/README.md index a9eff6e..87a205f 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,123 @@ -## ReolinkCameraAPI +

Reolink Python Api Client

-### Purpose +

+ Reolink Approval + GitHub + GitHub tag (latest SemVer) + PyPI + Discord +

-This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS ) +--- +A Reolink Camera client written in Python. This repository's purpose **(with Reolink's full support)** is to deliver a complete API for the Reolink Cameras, +although they have a basic API document - it does not satisfy the need for extensive camera communication. -### But Reolink gives an API in their documentation +Check out our documentation for more information on how to use the software at [https://reolink.oleaintueri.com](https://reolink.oleaintueri.com) -Not really. They only deliver a really basic API to retrieve Image data and Video data. -### How? +Other Supported Languages: + - Go: [reolinkapigo](https://github.com/ReolinkCameraAPI/reolinkapigo) -You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network. +### Join us on Discord + + https://discord.gg/8z3fdAmZJP + + +### Sponsorship + + + +[Oleaintueri](https://oleaintueri.com) is sponsoring the development and maintenance of these projects within their organisation. + + +--- ### Get started Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. +See the `examples` directory. + +### Using the library as a Python Module + +Install the package via PyPi + + pip install reolinkapi + +Install from GitHub + + pip install git+https://github.com/ReolinkCameraAPI/reolinkapipy.git + +If you want to include the video streaming functionality you need to include the streaming "extra" dependencies + + pip install 'reolinkapi[streaming]' + +## Contributors + +--- + +### Styling and Standards + +This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) + +### How can I become a contributor? + +#### Step 1 + +Get the Restful API calls by looking through the HTTP Requests made in the camera's web UI. I use Google Chrome developer mode (ctr + shift + i) -> Network. + +#### Step 2 + +- Fork the repository +- pip install -r requirements.txt +- Make your changes + +#### Step 3 + +Make a pull request. + ### API Requests Implementation Plan: +Stream: +- [X] Blocking RTSP stream +- [X] Non-Blocking RTSP stream + GET: - [X] Login -- [ ] Display -> OSD -- [ ] Recording -> Encode (Clear and Fluent Stream) -- [ ] Recording -> Advance (Scheduling) +- [X] Logout +- [X] Display -> OSD +- [X] Recording -> Encode (Clear and Fluent Stream) +- [X] Recording -> Advance (Scheduling) - [X] Network -> General - [X] Network -> Advanced -- [ ] Network -> DDNS -- [ ] Network -> NTP -- [ ] Network -> E-mail -- [ ] Network -> FTP -- [ ] Network -> Push +- [X] Network -> DDNS +- [X] Network -> NTP +- [X] Network -> E-mail +- [X] Network -> FTP +- [X] Network -> Push - [X] Network -> WIFI -- [ ] Alarm -> Motion +- [X] Alarm -> Motion - [X] System -> General -- [ ] System -> DST -- [ ] System -> Information +- [X] System -> DST +- [X] System -> Information - [ ] System -> Maintenance -- [ ] System -> Performance +- [X] System -> Performance - [ ] System -> Reboot -- [ ] User -> Online User -- [ ] User -> Add User -- [ ] User -> Manage User -- [ ] Device -> HDD/SD Card -- [ ] Zoom -- [ ] Focus -- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card +- [x] PTZ -> Presets, Calibration Status +- [x] Zoom +- [x] Focus +- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) - [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) -- [ ] Image Data +- [X] Image Data -> "Snap" Frame from Video Stream SET: -- [ ] Display -> OSD -- [ ] Recording -> Encode (Clear and Fluent Stream) +- [X] Display -> OSD +- [X] Recording -> Encode (Clear and Fluent Stream) - [ ] Recording -> Advance (Scheduling) - [X] Network -> General - [X] Network -> Advanced @@ -62,14 +128,32 @@ SET: - [ ] Network -> Push - [X] Network -> WIFI - [ ] Alarm -> Motion -- [X] System -> General +- [ ] System -> General - [ ] System -> DST -- [ ] System -> Reboot -- [ ] User -> Online User -- [ ] User -> Add User -- [ ] User -> Manage User -- [ ] Device -> HDD/SD Card -- [ ] Zoom -- [ ] Focus -- [ ] Image (Brightness, Contrass, Saturation, Hue, Sharp, Mirror, Rotate) -- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) +- [X] System -> Reboot +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card (Format) +- [x] PTZ (including calibrate) +- [x] Zoom +- [x] Focus +- [X] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) +- [X] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) + +### Supported Cameras + +Any Reolink camera that has a web UI should work. The other's requiring special Reolink clients +do not work and is not supported here. + +- RLC-411WS +- RLC-423 +- RLC-420-5MP +- RLC-410-5MP +- RLC-510A +- RLC-520 +- RLC-823A +- C1-Pro +- D400 +- E1 Zoom + diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..1fa5bdd --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,12 @@ +import reolinkapi + +if __name__ == "__main__": + cam = reolinkapi.Camera("192.168.0.102", defer_login=True) + + # must first login since I defer have deferred the login process + cam.login() + + dst = cam.get_dst() + ok = cam.add_user("foo", "bar", "admin") + alarm = cam.get_alarm_motion() + cam.set_device_name(name='my_camera') \ No newline at end of file diff --git a/examples/custom_ssl_session.py b/examples/custom_ssl_session.py new file mode 100644 index 0000000..bd13f5e --- /dev/null +++ b/examples/custom_ssl_session.py @@ -0,0 +1,30 @@ +from reolinkapi import Camera + +import urllib3 +import requests +from urllib3.util import create_urllib3_context + +class CustomSSLContextHTTPAdapter(requests.adapters.HTTPAdapter): + def __init__(self, ssl_context=None, **kwargs): + self.ssl_context = ssl_context + super().__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = urllib3.poolmanager.PoolManager( + num_pools=connections, maxsize=maxsize, + block=block, ssl_context=self.ssl_context) + +urllib3.disable_warnings() +ctx = create_urllib3_context() +ctx.load_default_certs() +ctx.set_ciphers("AES128-GCM-SHA256") +ctx.check_hostname = False + +session = requests.session() +session.adapters.pop("https://", None) +session.mount("https://", CustomSSLContextHTTPAdapter(ctx)) + +## Add a custom http handler to add in different ciphers that may +## not be aloud by default in openssl which urlib uses +cam = Camera("url", "user", "password", https=True, session=session) +cam.reboot_camera() diff --git a/examples/download_motions.py b/examples/download_motions.py new file mode 100644 index 0000000..809571f --- /dev/null +++ b/examples/download_motions.py @@ -0,0 +1,54 @@ +"""Downloads all motion events from camera from the past hour.""" +import os +from configparser import RawConfigParser +from datetime import datetime as dt, timedelta +from reolinkapi import Camera + + +def read_config(props_path: str) -> dict: + """Reads in a properties file into variables. + + NB! this config file is kept out of commits with .gitignore. The structure of this file is such: + # secrets.cfg + [camera] + ip={ip_address} + username={username} + password={password} + """ + config = RawConfigParser() + assert os.path.exists(props_path), f"Path does not exist: {props_path}" + config.read(props_path) + return config + + +# Read in your ip, username, & password +# (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure) +config = read_config('camera.cfg') + +ip = config.get('camera', 'ip') +un = config.get('camera', 'username') +pw = config.get('camera', 'password') + +# Connect to camera +cam = Camera(ip, un, pw, https=True) + +start = dt.combine(dt.now(), dt.min.time()) +end = dt.now() +# Collect motion events between these timestamps for substream +processed_motions = cam.get_motion_files(start=start, end=end, streamtype='main', channel=0) +processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + +start = dt.now() - timedelta(days=1) +end = dt.combine(start, dt.max.time()) +processed_motions += cam.get_motion_files(start=start, end=end, streamtype='main', channel=1) + + +output_files = [] +for i, motion in enumerate(processed_motions): + fname = motion['filename'] + # Download the mp4 + print("Getting %s" % (fname)) + output_path = os.path.join('/tmp/', fname.replace('/','_')) + output_files += output_path + if not os.path.isfile(output_path): + resp = cam.get_file(fname, output_path=output_path) diff --git a/examples/download_playback_video.py b/examples/download_playback_video.py new file mode 100644 index 0000000..85f8202 --- /dev/null +++ b/examples/download_playback_video.py @@ -0,0 +1,46 @@ +"""Downloads a video from camera from start to end time.""" +import os +from configparser import RawConfigParser +from datetime import datetime as dt, timedelta +from reolinkapi import Camera +import requests +import pandas as pd + +def read_config(props_path: str) -> dict: + """Reads in a properties file into variables. + + NB! this config file is kept out of commits with .gitignore. The structure of this file is such: + # secrets.cfg + [camera] + ip={ip_address} + username={username} + password={password} + """ + config = RawConfigParser() + assert os.path.exists(props_path), f"Path does not exist: {props_path}" + config.read(props_path) + return config + + +# Read in your ip, username, & password +# (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure) +config = read_config('camera.cfg') + +ip = config.get('camera', 'ip') +un = config.get('camera', 'username') +pw = config.get('camera', 'password') + +# Connect to camera +cam = Camera(ip, un, pw) + +start = dt.now() - timedelta(minutes=10) +end = dt.now() - timedelta(minutes=9) +channel = 0 + +files = cam.get_playback_files(start=start, end=end, channel= channel) +print(files) +dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads') +for fname in files: + print(fname) + # Download the mp4 + cam.get_file(fname, output_path=os.path.join(dl_dir, fname)) diff --git a/examples/network_config.py b/examples/network_config.py new file mode 100644 index 0000000..51b1956 --- /dev/null +++ b/examples/network_config.py @@ -0,0 +1,62 @@ +import os +from configparser import RawConfigParser +from reolinkapi import Camera + + +def read_config(props_path: str) -> dict: + """Reads in a properties file into variables. + + NB! this config file is kept out of commits with .gitignore. The structure of this file is such: + # secrets.cfg + [camera] + ip={ip_address} + username={username} + password={password} + """ + config = RawConfigParser() + assert os.path.exists(props_path), f"Path does not exist: {props_path}" + config.read(props_path) + return config + + +# Read in your ip, username, & password +# (NB! you'll likely have to create this file. See tests/test_camera.py for details on structure) +config = read_config('camera.cfg') + +ip = config.get('camera', 'ip') +un = config.get('camera', 'username') +pw = config.get('camera', 'password') + +# Connect to camera +cam = Camera(ip, un, pw) + +# Set NTP +cam.set_ntp(enable=True, interval=1440, port=123, server="time-b.nist.gov") + +# Get current network settings +current_settings = cam.get_network_general() +print("Current settings:", current_settings) + +# Configure DHCP +cam.set_network_settings( + ip="", + gateway="", + mask="", + dns1="", + dns2="", + mac=current_settings[0]['value']['LocalLink']['mac'], + use_dhcp=True, + auto_dns=True +) + +# Configure static IP +# cam.set_network_settings( +# ip="192.168.1.102", +# gateway="192.168.1.1", +# mask="255.255.255.0", +# dns1="8.8.8.8", +# dns2="8.8.4.4", +# mac=current_settings[0]['value']['LocalLink']['mac'], +# use_dhcp=False, +# auto_dns=False +# ) \ No newline at end of file diff --git a/examples/response/GetAlarmMotion.json b/examples/response/GetAlarmMotion.json new file mode 100644 index 0000000..56be0cc --- /dev/null +++ b/examples/response/GetAlarmMotion.json @@ -0,0 +1,150 @@ +[ + { + "cmd": "GetAlarm", + "code": 0, + "initial": { + "Alarm": { + "action": { "mail": 1, "push": 1, "recChannel": [0] }, + "channel": 0, + "enable": 1, + "schedule": { + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "scope": { + "cols": 80, + "rows": 45, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "sens": [ + { + "beginHour": 0, + "beginMin": 0, + "endHour": 6, + "endMin": 0, + "sensitivity": 10 + }, + { + "beginHour": 6, + "beginMin": 0, + "endHour": 12, + "endMin": 0, + "sensitivity": 10 + }, + { + "beginHour": 12, + "beginMin": 0, + "endHour": 18, + "endMin": 0, + "sensitivity": 10 + }, + { + "beginHour": 18, + "beginMin": 0, + "endHour": 23, + "endMin": 59, + "sensitivity": 10 + } + ], + "type": "md" + } + }, + "range": { + "Alarm": { + "action": { "mail": "boolean", "push": "boolean", "recChannel": [0] }, + "channel": 0, + "enable": "boolean", + "schedule": { "table": { "maxLen": 168, "minLen": 168 } }, + "scope": { + "cols": { "max": 80, "min": 80 }, + "rows": { "max": 45, "min": 45 }, + "table": { "maxLen": 8159 } + }, + "sens": [ + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 0, + "sensitivity": { "max": 50, "min": 1 } + }, + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 1, + "sensitivity": { "max": 50, "min": 1 } + }, + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 2, + "sensitivity": { "max": 50, "min": 1 } + }, + { + "beginHour": { "max": 23, "min": 0 }, + "beginMin": { "max": 59, "min": 0 }, + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "id": 3, + "sensitivity": { "max": 50, "min": 1 } + } + ], + "type": "md" + } + }, + "value": { + "Alarm": { + "action": { "mail": 1, "push": 1, "recChannel": [0] }, + "channel": 0, + "enable": 1, + "schedule": { + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "scope": { + "cols": 80, + "rows": 45, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "sens": [ + { + "beginHour": 0, + "beginMin": 0, + "endHour": 6, + "endMin": 0, + "id": 0, + "sensitivity": 10 + }, + { + "beginHour": 6, + "beginMin": 0, + "endHour": 12, + "endMin": 0, + "id": 1, + "sensitivity": 10 + }, + { + "beginHour": 12, + "beginMin": 0, + "endHour": 18, + "endMin": 0, + "id": 2, + "sensitivity": 10 + }, + { + "beginHour": 18, + "beginMin": 0, + "endHour": 23, + "endMin": 59, + "id": 3, + "sensitivity": 10 + } + ], + "type": "md" + } + } + } +] diff --git a/examples/response/GetDSTInfo.json b/examples/response/GetDSTInfo.json new file mode 100644 index 0000000..5895d9e --- /dev/null +++ b/examples/response/GetDSTInfo.json @@ -0,0 +1,35 @@ +[ + { + "cmd": "GetTime", + "code": 0, + "value": { + "Dst": { + "enable": 1, + "endHour": 2, + "endMin": 0, + "endMon": 11, + "endSec": 0, + "endWeek": 1, + "endWeekday": 0, + "offset": 1, + "startHour": 2, + "startMin": 0, + "startMon": 3, + "startSec": 0, + "startWeek": 1, + "startWeekday": 0 + }, + "Time": { + "day": 27, + "hour": 18, + "hourFmt": 0, + "min": 50, + "mon": 10, + "sec": 46, + "timeFmt": "MM/DD/YYYY", + "timeZone": 21600, + "year": 2020 + } + } + } +] diff --git a/examples/response/GetDevInfo.json b/examples/response/GetDevInfo.json new file mode 100644 index 0000000..9018183 --- /dev/null +++ b/examples/response/GetDevInfo.json @@ -0,0 +1,26 @@ +[ + { + "cmd": "GetDevInfo", + "code": 0, + "value": { + "DevInfo": { + "B485": 0, + "IOInputNum": 0, + "IOOutputNum": 0, + "audioNum": 0, + "buildDay": "build 18081408", + "cfgVer": "v2.0.0.0", + "channelNum": 1, + "detail": "IPC_3816M100000000100000", + "diskNum": 1, + "firmVer": "v2.0.0.1389_18081408", + "hardVer": "IPC_3816M", + "model": "RLC-411WS", + "name": "Camera1_withpersonality", + "serial": "00000000000000", + "type": "IPC", + "wifi": 1 + } + } + } +] diff --git a/examples/response/GetEnc.json b/examples/response/GetEnc.json new file mode 100644 index 0000000..547ed6f --- /dev/null +++ b/examples/response/GetEnc.json @@ -0,0 +1,377 @@ +[ + { + "cmd": "GetEnc", + "code": 0, + "initial": { + "Enc": { + "audio": 0, + "channel": 0, + "mainStream": { + "bitRate": 4096, + "frameRate": 15, + "profile": "High", + "size": "3072*1728" + }, + "subStream": { + "bitRate": 160, + "frameRate": 7, + "profile": "High", + "size": "640*360" + } + } + }, + "range": { + "Enc": [ + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 4096, + "frameRate": 15 + }, + "frameRate": [ + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "3072*1728" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 4096, + "frameRate": 15 + }, + "frameRate": [ + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2592*1944" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 3072, + "frameRate": 15 + }, + "frameRate": [ + 30, + 22, + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2560*1440" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 3072, + "frameRate": 15 + }, + "frameRate": [ + 30, + 22, + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2048*1536" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + }, + { + "audio": "boolean", + "mainStream": { + "bitRate": [ + 1024, + 1536, + 2048, + 3072, + 4096, + 5120, + 6144, + 7168, + 8192 + ], + "default": { + "bitRate": 3072, + "frameRate": 15 + }, + "frameRate": [ + 30, + 22, + 20, + 18, + 16, + 15, + 12, + 10, + 8, + 6, + 4, + 2 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "2304*1296" + }, + "subStream": { + "bitRate": [ + 64, + 128, + 160, + 192, + 256, + 384, + 512 + ], + "default": { + "bitRate": 160, + "frameRate": 7 + }, + "frameRate": [ + 15, + 10, + 7, + 4 + ], + "profile": [ + "Base", + "Main", + "High" + ], + "size": "640*360" + } + } + ] + }, + "value": { + "Enc": { + "audio": 0, + "channel": 0, + "mainStream": { + "bitRate": 2048, + "frameRate": 20, + "profile": "Main", + "size": "3072*1728" + }, + "subStream": { + "bitRate": 64, + "frameRate": 4, + "profile": "High", + "size": "640*360" + } + } + } + } +] diff --git a/examples/response/GetGeneralSystem.json b/examples/response/GetGeneralSystem.json new file mode 100644 index 0000000..814671e --- /dev/null +++ b/examples/response/GetGeneralSystem.json @@ -0,0 +1,100 @@ +[ + { + "cmd": "GetTime", + "code": 0, + "initial": { + "Dst": { + "enable": 0, + "endHour": 2, + "endMin": 0, + "endMon": 10, + "endSec": 0, + "endWeek": 5, + "endWeekday": 0, + "offset": 1, + "startHour": 2, + "startMin": 0, + "startMon": 3, + "startSec": 0, + "startWeek": 2, + "startWeekday": 0 + }, + "Time": { + "day": 1, + "hour": 0, + "hourFmt": 0, + "min": 0, + "mon": 0, + "sec": 0, + "timeFmt": "DD/MM/YYYY", + "timeZone": 28800, + "year": 0 + } + }, + "range": { + "Dst": { + "enable": "boolean", + "endHour": { "max": 23, "min": 0 }, + "endMin": { "max": 59, "min": 0 }, + "endMon": { "max": 12, "min": 1 }, + "endSec": { "max": 59, "min": 0 }, + "endWeek": { "max": 5, "min": 1 }, + "endWeekday": { "max": 6, "min": 0 }, + "offset": { "max": 2, "min": 1 }, + "startHour": { "max": 23, "min": 0 }, + "startMin": { "max": 59, "min": 0 }, + "startMon": { "max": 12, "min": 1 }, + "startSec": { "max": 59, "min": 0 }, + "startWeek": { "max": 5, "min": 1 }, + "startWeekday": { "max": 6, "min": 0 } + }, + "Time": { + "day": { "max": 31, "min": 1 }, + "hour": { "max": 23, "min": 0 }, + "hourFmt": { "max": 1, "min": 0 }, + "min": { "max": 59, "min": 0 }, + "mon": { "max": 12, "min": 1 }, + "sec": { "max": 59, "min": 0 }, + "timeFmt": ["MM/DD/YYYY", "YYYY/MM/DD", "DD/MM/YYYY"], + "timeZone": { "max": 43200, "min": -46800 }, + "year": { "max": 2100, "min": 1900 } + } + }, + "value": { + "Dst": { + "enable": 1, + "endHour": 2, + "endMin": 0, + "endMon": 11, + "endSec": 0, + "endWeek": 1, + "endWeekday": 0, + "offset": 1, + "startHour": 2, + "startMin": 0, + "startMon": 3, + "startSec": 0, + "startWeek": 1, + "startWeekday": 0 + }, + "Time": { + "day": 9, + "hour": 15, + "hourFmt": 0, + "min": 33, + "mon": 12, + "sec": 58, + "timeFmt": "MM/DD/YYYY", + "timeZone": 21600, + "year": 2020 + } + } + }, + { + "cmd": "GetNorm", + "code": 0, + "initial": { "norm": "NTSC" }, + "range": { "norm": ["PAL", "NTSC"] }, + "value": { "norm": "NTSC" } + } +] diff --git a/examples/response/GetHddInfo.json b/examples/response/GetHddInfo.json new file mode 100644 index 0000000..d95b7aa --- /dev/null +++ b/examples/response/GetHddInfo.json @@ -0,0 +1,17 @@ +[ + { + "cmd": "GetHddInfo", + "code": 0, + "value": { + "HddInfo": [ + { + "capacity": 15181, + "format": 1, + "id": 0, + "mount": 1, + "size": 15181 + } + ] + } + } +] diff --git a/examples/response/GetMask.json b/examples/response/GetMask.json new file mode 100644 index 0000000..beb345a --- /dev/null +++ b/examples/response/GetMask.json @@ -0,0 +1,40 @@ +[ + { + "cmd": "GetMask", + "code": 0, + "initial": { + "Mask": { + "area": [ + { + "block": { + "height": 0, + "width": 0, + "x": 0, + "y": 0 + }, + "screen": { + "height": 0, + "width": 0 + } + } + ], + "channel": 0, + "enable": 0 + } + }, + "range": { + "Mask": { + "channel": 0, + "enable": "boolean", + "maxAreas": 4 + } + }, + "value": { + "Mask": { + "area": null, + "channel": 0, + "enable": 0 + } + } + } +] diff --git a/examples/response/GetNetworkAdvanced.json b/examples/response/GetNetworkAdvanced.json new file mode 100644 index 0000000..25c729e --- /dev/null +++ b/examples/response/GetNetworkAdvanced.json @@ -0,0 +1,42 @@ +[ + { + "cmd": "GetNetPort", + "code": 0, + "initial": { + "NetPort": { + "httpPort": 80, + "httpsPort": 443, + "mediaPort": 9000, + "onvifPort": 8000, + "rtmpPort": 1935, + "rtspPort": 554 + } + }, + "range": { + "NetPort": { + "httpPort": { "max": 65535, "min": 1 }, + "httpsPort": { "max": 65535, "min": 1 }, + "mediaPort": { "max": 65535, "min": 1 }, + "onvifPort": { "max": 65535, "min": 1 }, + "rtmpPort": { "max": 65535, "min": 1 }, + "rtspPort": { "max": 65535, "min": 1 } + } + }, + "value": { + "NetPort": { + "httpPort": 80, + "httpsPort": 443, + "mediaPort": 9000, + "onvifPort": 8000, + "rtmpPort": 1935, + "rtspPort": 554 + } + } + }, + { "cmd": "GetUpnp", "code": 0, "value": { "Upnp": { "enable": 0 } } }, + { + "cmd": "GetP2p", + "code": 0, + "value": { "P2p": { "enable": 0, "uid": "99999999999999" } } + } +] diff --git a/examples/response/GetNetworkDDNS.json b/examples/response/GetNetworkDDNS.json new file mode 100644 index 0000000..a79127a --- /dev/null +++ b/examples/response/GetNetworkDDNS.json @@ -0,0 +1,15 @@ +[ + { + "cmd": "GetDdns", + "code": 0, + "value": { + "Ddns": { + "domain": "", + "enable": 0, + "password": "", + "type": "no-ip", + "userName": "" + } + } + } +] diff --git a/examples/response/GetNetworkEmail.json b/examples/response/GetNetworkEmail.json new file mode 100644 index 0000000..9595a2a --- /dev/null +++ b/examples/response/GetNetworkEmail.json @@ -0,0 +1,25 @@ +[ + { + "cmd": "GetEmail", + "code": 0, + "value": { + "Email": { + "addr1": "", + "addr2": "", + "addr3": "", + "attachment": "picture", + "interval": "5 Minutes", + "nickName": "", + "password": "", + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "smtpPort": 465, + "smtpServer": "smtp.gmail.com", + "ssl": 1, + "userName": "" + } + } + } +] diff --git a/examples/response/GetNetworkFtp.json b/examples/response/GetNetworkFtp.json new file mode 100644 index 0000000..8ba4622 --- /dev/null +++ b/examples/response/GetNetworkFtp.json @@ -0,0 +1,24 @@ +[ + { + "cmd": "GetFtp", + "code": 0, + "value": { + "Ftp": { + "anonymous": 0, + "interval": 30, + "maxSize": 100, + "mode": 0, + "password": "", + "port": 21, + "remoteDir": "", + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "server": "", + "streamType": 0, + "userName": "" + } + } + } +] diff --git a/examples/response/GetNetworkGeneral.json b/examples/response/GetNetworkGeneral.json new file mode 100644 index 0000000..958bbdd --- /dev/null +++ b/examples/response/GetNetworkGeneral.json @@ -0,0 +1,19 @@ +[ + { + "cmd": "GetLocalLink", + "code": 0, + "value": { + "LocalLink": { + "activeLink": "LAN", + "dns": { "auto": 1, "dns1": "192.168.255.4", "dns2": "192.168.255.4" }, + "mac": "EC:71:DB:AA:59:CF", + "static": { + "gateway": "192.168.255.1", + "ip": "192.168.255.58", + "mask": "255.255.255.0" + }, + "type": "DHCP" + } + } + } +] diff --git a/examples/response/GetNetworkNTP.json b/examples/response/GetNetworkNTP.json new file mode 100644 index 0000000..7293fed --- /dev/null +++ b/examples/response/GetNetworkNTP.json @@ -0,0 +1,14 @@ +[ + { + "cmd": "GetNtp", + "code": 0, + "value": { + "Ntp": { + "enable": 1, + "interval": 1440, + "port": 123, + "server": "ntp.moos.xyz" + } + } + } +] diff --git a/examples/response/GetNetworkPush.json b/examples/response/GetNetworkPush.json new file mode 100644 index 0000000..399207f --- /dev/null +++ b/examples/response/GetNetworkPush.json @@ -0,0 +1,14 @@ +[ + { + "cmd": "GetPush", + "code": 0, + "value": { + "Push": { + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + } + } + } + } +] diff --git a/examples/response/GetOnline.json b/examples/response/GetOnline.json new file mode 100644 index 0000000..34f59d1 --- /dev/null +++ b/examples/response/GetOnline.json @@ -0,0 +1,17 @@ +[ + { + "cmd": "GetOnline", + "code": 0, + "value": { + "User": [ + { + "canbeDisconn": 0, + "ip": "192.168.1.100", + "level": "admin", + "sessionId": 1000, + "userName": "admin" + } + ] + } + } +] diff --git a/examples/response/GetOsd.json b/examples/response/GetOsd.json new file mode 100644 index 0000000..e925b90 --- /dev/null +++ b/examples/response/GetOsd.json @@ -0,0 +1,67 @@ +[ + { + "cmd": "GetOsd", + "code": 0, + "initial": { + "Osd": { + "bgcolor": 0, + "channel": 0, + "osdChannel": { + "enable": 1, + "name": "Camera1", + "pos": "Lower Right" + }, + "osdTime": { + "enable": 1, + "pos": "Top Center" + } + } + }, + "range": { + "Osd": { + "bgcolor": "boolean", + "channel": 0, + "osdChannel": { + "enable": "boolean", + "name": { + "maxLen": 31 + }, + "pos": [ + "Upper Left", + "Top Center", + "Upper Right", + "Lower Left", + "Bottom Center", + "Lower Right" + ] + }, + "osdTime": { + "enable": "boolean", + "pos": [ + "Upper Left", + "Top Center", + "Upper Right", + "Lower Left", + "Bottom Center", + "Lower Right" + ] + } + } + }, + "value": { + "Osd": { + "bgcolor": 0, + "channel": 0, + "osdChannel": { + "enable": 0, + "name": "FarRight", + "pos": "Lower Right" + }, + "osdTime": { + "enable": 0, + "pos": "Top Center" + } + } + } + } +] \ No newline at end of file diff --git a/examples/response/GetPerformance.json b/examples/response/GetPerformance.json new file mode 100644 index 0000000..73bc9ba --- /dev/null +++ b/examples/response/GetPerformance.json @@ -0,0 +1,13 @@ +[ + { + "cmd": "GetPerformance", + "code": 0, + "value": { + "Performance": { + "codecRate": 2154, + "cpuUsed": 14, + "netThroughput": 0 + } + } + } +] diff --git a/examples/response/GetPtzCheckState.json b/examples/response/GetPtzCheckState.json new file mode 100644 index 0000000..55386ec --- /dev/null +++ b/examples/response/GetPtzCheckState.json @@ -0,0 +1,19 @@ +[ + { + "cmd": "GetPtzCheckState", + "code": 0, + "initial": { + "PtzCheckState": 2 + }, + "range": { + "PtzCheckState": [ + 0, + 1, + 2 + ] + }, + "value": { + "PtzCheckState": 2 + } + } +] diff --git a/examples/response/GetPtzPresets.json b/examples/response/GetPtzPresets.json new file mode 100644 index 0000000..b89dad3 --- /dev/null +++ b/examples/response/GetPtzPresets.json @@ -0,0 +1,926 @@ +[ + { + "cmd": "GetPtzPreset", + "code": 0, + "initial": { + "PtzPreset": [ + { + "channel": 0, + "enable": 1, + "id": 0, + "imgName": "preset_00", + "name": "namepos0" + }, + { + "channel": 0, + "enable": 1, + "id": 1, + "imgName": "preset_01", + "name": "othernamepos1" + }, + { + "channel": 0, + "enable": 0, + "id": 2, + "imgName": "", + "name": "pos3" + }, + { + "channel": 0, + "enable": 0, + "id": 3, + "imgName": "", + "name": "pos4" + }, + { + "channel": 0, + "enable": 0, + "id": 4, + "imgName": "", + "name": "pos5" + }, + { + "channel": 0, + "enable": 0, + "id": 5, + "imgName": "", + "name": "pos6" + }, + { + "channel": 0, + "enable": 0, + "id": 6, + "imgName": "", + "name": "pos7" + }, + { + "channel": 0, + "enable": 0, + "id": 7, + "imgName": "", + "name": "pos8" + }, + { + "channel": 0, + "enable": 0, + "id": 8, + "imgName": "", + "name": "pos9" + }, + { + "channel": 0, + "enable": 0, + "id": 9, + "imgName": "", + "name": "pos10" + }, + { + "channel": 0, + "enable": 0, + "id": 10, + "imgName": "", + "name": "pos11" + }, + { + "channel": 0, + "enable": 0, + "id": 11, + "imgName": "", + "name": "pos12" + }, + { + "channel": 0, + "enable": 0, + "id": 12, + "imgName": "", + "name": "pos13" + }, + { + "channel": 0, + "enable": 0, + "id": 13, + "imgName": "", + "name": "pos14" + }, + { + "channel": 0, + "enable": 0, + "id": 14, + "imgName": "", + "name": "pos15" + }, + { + "channel": 0, + "enable": 0, + "id": 15, + "imgName": "", + "name": "pos16" + }, + { + "channel": 0, + "enable": 0, + "id": 16, + "imgName": "", + "name": "pos17" + }, + { + "channel": 0, + "enable": 0, + "id": 17, + "imgName": "", + "name": "pos18" + }, + { + "channel": 0, + "enable": 0, + "id": 18, + "imgName": "", + "name": "pos19" + }, + { + "channel": 0, + "enable": 0, + "id": 19, + "imgName": "", + "name": "pos20" + }, + { + "channel": 0, + "enable": 0, + "id": 20, + "imgName": "", + "name": "pos21" + }, + { + "channel": 0, + "enable": 0, + "id": 21, + "imgName": "", + "name": "pos22" + }, + { + "channel": 0, + "enable": 0, + "id": 22, + "imgName": "", + "name": "pos23" + }, + { + "channel": 0, + "enable": 0, + "id": 23, + "imgName": "", + "name": "pos24" + }, + { + "channel": 0, + "enable": 0, + "id": 24, + "imgName": "", + "name": "pos25" + }, + { + "channel": 0, + "enable": 0, + "id": 25, + "imgName": "", + "name": "pos26" + }, + { + "channel": 0, + "enable": 0, + "id": 26, + "imgName": "", + "name": "pos27" + }, + { + "channel": 0, + "enable": 0, + "id": 27, + "imgName": "", + "name": "pos28" + }, + { + "channel": 0, + "enable": 0, + "id": 28, + "imgName": "", + "name": "pos29" + }, + { + "channel": 0, + "enable": 0, + "id": 29, + "imgName": "", + "name": "pos30" + }, + { + "channel": 0, + "enable": 0, + "id": 30, + "imgName": "", + "name": "pos31" + }, + { + "channel": 0, + "enable": 0, + "id": 31, + "imgName": "", + "name": "pos32" + }, + { + "channel": 0, + "enable": 0, + "id": 32, + "imgName": "", + "name": "pos33" + }, + { + "channel": 0, + "enable": 0, + "id": 33, + "imgName": "", + "name": "pos34" + }, + { + "channel": 0, + "enable": 0, + "id": 34, + "imgName": "", + "name": "pos35" + }, + { + "channel": 0, + "enable": 0, + "id": 35, + "imgName": "", + "name": "pos36" + }, + { + "channel": 0, + "enable": 0, + "id": 36, + "imgName": "", + "name": "pos37" + }, + { + "channel": 0, + "enable": 0, + "id": 37, + "imgName": "", + "name": "pos38" + }, + { + "channel": 0, + "enable": 0, + "id": 38, + "imgName": "", + "name": "pos39" + }, + { + "channel": 0, + "enable": 0, + "id": 39, + "imgName": "", + "name": "pos40" + }, + { + "channel": 0, + "enable": 0, + "id": 40, + "imgName": "", + "name": "pos41" + }, + { + "channel": 0, + "enable": 0, + "id": 41, + "imgName": "", + "name": "pos42" + }, + { + "channel": 0, + "enable": 0, + "id": 42, + "imgName": "", + "name": "pos43" + }, + { + "channel": 0, + "enable": 0, + "id": 43, + "imgName": "", + "name": "pos44" + }, + { + "channel": 0, + "enable": 0, + "id": 44, + "imgName": "", + "name": "pos45" + }, + { + "channel": 0, + "enable": 0, + "id": 45, + "imgName": "", + "name": "pos46" + }, + { + "channel": 0, + "enable": 0, + "id": 46, + "imgName": "", + "name": "pos47" + }, + { + "channel": 0, + "enable": 0, + "id": 47, + "imgName": "", + "name": "pos48" + }, + { + "channel": 0, + "enable": 0, + "id": 48, + "imgName": "", + "name": "pos49" + }, + { + "channel": 0, + "enable": 0, + "id": 49, + "imgName": "", + "name": "pos50" + }, + { + "channel": 0, + "enable": 0, + "id": 50, + "imgName": "", + "name": "pos51" + }, + { + "channel": 0, + "enable": 0, + "id": 51, + "imgName": "", + "name": "pos52" + }, + { + "channel": 0, + "enable": 0, + "id": 52, + "imgName": "", + "name": "pos53" + }, + { + "channel": 0, + "enable": 0, + "id": 53, + "imgName": "", + "name": "pos54" + }, + { + "channel": 0, + "enable": 0, + "id": 54, + "imgName": "", + "name": "pos55" + }, + { + "channel": 0, + "enable": 0, + "id": 55, + "imgName": "", + "name": "pos56" + }, + { + "channel": 0, + "enable": 0, + "id": 56, + "imgName": "", + "name": "pos57" + }, + { + "channel": 0, + "enable": 0, + "id": 57, + "imgName": "", + "name": "pos58" + }, + { + "channel": 0, + "enable": 0, + "id": 58, + "imgName": "", + "name": "pos59" + }, + { + "channel": 0, + "enable": 0, + "id": 59, + "imgName": "", + "name": "pos60" + }, + { + "channel": 0, + "enable": 0, + "id": 60, + "imgName": "", + "name": "pos61" + }, + { + "channel": 0, + "enable": 0, + "id": 61, + "imgName": "", + "name": "pos62" + }, + { + "channel": 0, + "enable": 0, + "id": 62, + "imgName": "", + "name": "pos63" + }, + { + "channel": 0, + "enable": 0, + "id": 63, + "imgName": "", + "name": "pos64" + } + ] + }, + "range": { + "PtzPreset": { + "channel": 0, + "enable": "boolean", + "id": { + "max": 64, + "min": 1 + }, + "imgName": { + "maxLen": 31 + }, + "name": { + "maxLen": 31 + } + } + }, + "value": { + "PtzPreset": [ + { + "channel": 0, + "enable": 1, + "id": 0, + "imgName": "preset_00", + "name": "0" + }, + { + "channel": 0, + "enable": 1, + "id": 1, + "imgName": "preset_01", + "name": "1" + }, + { + "channel": 0, + "enable": 0, + "id": 2, + "imgName": "", + "name": "pos3" + }, + { + "channel": 0, + "enable": 0, + "id": 3, + "imgName": "", + "name": "pos4" + }, + { + "channel": 0, + "enable": 0, + "id": 4, + "imgName": "", + "name": "pos5" + }, + { + "channel": 0, + "enable": 0, + "id": 5, + "imgName": "", + "name": "pos6" + }, + { + "channel": 0, + "enable": 0, + "id": 6, + "imgName": "", + "name": "pos7" + }, + { + "channel": 0, + "enable": 0, + "id": 7, + "imgName": "", + "name": "pos8" + }, + { + "channel": 0, + "enable": 0, + "id": 8, + "imgName": "", + "name": "pos9" + }, + { + "channel": 0, + "enable": 0, + "id": 9, + "imgName": "", + "name": "pos10" + }, + { + "channel": 0, + "enable": 0, + "id": 10, + "imgName": "", + "name": "pos11" + }, + { + "channel": 0, + "enable": 0, + "id": 11, + "imgName": "", + "name": "pos12" + }, + { + "channel": 0, + "enable": 0, + "id": 12, + "imgName": "", + "name": "pos13" + }, + { + "channel": 0, + "enable": 0, + "id": 13, + "imgName": "", + "name": "pos14" + }, + { + "channel": 0, + "enable": 0, + "id": 14, + "imgName": "", + "name": "pos15" + }, + { + "channel": 0, + "enable": 0, + "id": 15, + "imgName": "", + "name": "pos16" + }, + { + "channel": 0, + "enable": 0, + "id": 16, + "imgName": "", + "name": "pos17" + }, + { + "channel": 0, + "enable": 0, + "id": 17, + "imgName": "", + "name": "pos18" + }, + { + "channel": 0, + "enable": 0, + "id": 18, + "imgName": "", + "name": "pos19" + }, + { + "channel": 0, + "enable": 0, + "id": 19, + "imgName": "", + "name": "pos20" + }, + { + "channel": 0, + "enable": 0, + "id": 20, + "imgName": "", + "name": "pos21" + }, + { + "channel": 0, + "enable": 0, + "id": 21, + "imgName": "", + "name": "pos22" + }, + { + "channel": 0, + "enable": 0, + "id": 22, + "imgName": "", + "name": "pos23" + }, + { + "channel": 0, + "enable": 0, + "id": 23, + "imgName": "", + "name": "pos24" + }, + { + "channel": 0, + "enable": 0, + "id": 24, + "imgName": "", + "name": "pos25" + }, + { + "channel": 0, + "enable": 0, + "id": 25, + "imgName": "", + "name": "pos26" + }, + { + "channel": 0, + "enable": 0, + "id": 26, + "imgName": "", + "name": "pos27" + }, + { + "channel": 0, + "enable": 0, + "id": 27, + "imgName": "", + "name": "pos28" + }, + { + "channel": 0, + "enable": 0, + "id": 28, + "imgName": "", + "name": "pos29" + }, + { + "channel": 0, + "enable": 0, + "id": 29, + "imgName": "", + "name": "pos30" + }, + { + "channel": 0, + "enable": 0, + "id": 30, + "imgName": "", + "name": "pos31" + }, + { + "channel": 0, + "enable": 0, + "id": 31, + "imgName": "", + "name": "pos32" + }, + { + "channel": 0, + "enable": 0, + "id": 32, + "imgName": "", + "name": "pos33" + }, + { + "channel": 0, + "enable": 0, + "id": 33, + "imgName": "", + "name": "pos34" + }, + { + "channel": 0, + "enable": 0, + "id": 34, + "imgName": "", + "name": "pos35" + }, + { + "channel": 0, + "enable": 0, + "id": 35, + "imgName": "", + "name": "pos36" + }, + { + "channel": 0, + "enable": 0, + "id": 36, + "imgName": "", + "name": "pos37" + }, + { + "channel": 0, + "enable": 0, + "id": 37, + "imgName": "", + "name": "pos38" + }, + { + "channel": 0, + "enable": 0, + "id": 38, + "imgName": "", + "name": "pos39" + }, + { + "channel": 0, + "enable": 0, + "id": 39, + "imgName": "", + "name": "pos40" + }, + { + "channel": 0, + "enable": 0, + "id": 40, + "imgName": "", + "name": "pos41" + }, + { + "channel": 0, + "enable": 0, + "id": 41, + "imgName": "", + "name": "pos42" + }, + { + "channel": 0, + "enable": 0, + "id": 42, + "imgName": "", + "name": "pos43" + }, + { + "channel": 0, + "enable": 0, + "id": 43, + "imgName": "", + "name": "pos44" + }, + { + "channel": 0, + "enable": 0, + "id": 44, + "imgName": "", + "name": "pos45" + }, + { + "channel": 0, + "enable": 0, + "id": 45, + "imgName": "", + "name": "pos46" + }, + { + "channel": 0, + "enable": 0, + "id": 46, + "imgName": "", + "name": "pos47" + }, + { + "channel": 0, + "enable": 0, + "id": 47, + "imgName": "", + "name": "pos48" + }, + { + "channel": 0, + "enable": 0, + "id": 48, + "imgName": "", + "name": "pos49" + }, + { + "channel": 0, + "enable": 0, + "id": 49, + "imgName": "", + "name": "pos50" + }, + { + "channel": 0, + "enable": 0, + "id": 50, + "imgName": "", + "name": "pos51" + }, + { + "channel": 0, + "enable": 0, + "id": 51, + "imgName": "", + "name": "pos52" + }, + { + "channel": 0, + "enable": 0, + "id": 52, + "imgName": "", + "name": "pos53" + }, + { + "channel": 0, + "enable": 0, + "id": 53, + "imgName": "", + "name": "pos54" + }, + { + "channel": 0, + "enable": 0, + "id": 54, + "imgName": "", + "name": "pos55" + }, + { + "channel": 0, + "enable": 0, + "id": 55, + "imgName": "", + "name": "pos56" + }, + { + "channel": 0, + "enable": 0, + "id": 56, + "imgName": "", + "name": "pos57" + }, + { + "channel": 0, + "enable": 0, + "id": 57, + "imgName": "", + "name": "pos58" + }, + { + "channel": 0, + "enable": 0, + "id": 58, + "imgName": "", + "name": "pos59" + }, + { + "channel": 0, + "enable": 0, + "id": 59, + "imgName": "", + "name": "pos60" + }, + { + "channel": 0, + "enable": 0, + "id": 60, + "imgName": "", + "name": "pos61" + }, + { + "channel": 0, + "enable": 0, + "id": 61, + "imgName": "", + "name": "pos62" + }, + { + "channel": 0, + "enable": 0, + "id": 62, + "imgName": "", + "name": "pos63" + }, + { + "channel": 0, + "enable": 0, + "id": 63, + "imgName": "", + "name": "pos64" + } + ] + } + } +] diff --git a/examples/response/GetRec.json b/examples/response/GetRec.json new file mode 100644 index 0000000..623c353 --- /dev/null +++ b/examples/response/GetRec.json @@ -0,0 +1,45 @@ +[ + { + "cmd": "GetRec", + "code": 0, + "initial": { + "Rec": { + "channel": 0, + "overwrite": 1, + "postRec": "15 Seconds", + "preRec": 1, + "schedule": { + "enable": 1, + "table": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + } + } + }, + "range": { + "Rec": { + "channel": 0, + "overwrite": "boolean", + "postRec": [ + "15 Seconds", + "30 Seconds", + "1 Minute" + ], + "preRec": "boolean", + "schedule": { + "enable": "boolean" + } + } + }, + "value": { + "Rec": { + "channel": 0, + "overwrite": 1, + "postRec": "15 Seconds", + "preRec": 1, + "schedule": { + "enable": 1, + "table": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + } + } +] diff --git a/examples/response/GetUser.json b/examples/response/GetUser.json new file mode 100644 index 0000000..d3d77e7 --- /dev/null +++ b/examples/response/GetUser.json @@ -0,0 +1,35 @@ +[ + { + "cmd": "GetUser", + "code": 0, + "initial": { + "User": { + "level": "guest" + } + }, + "range": { + "User": { + "level": [ + "guest", + "admin" + ], + "password": { + "maxLen": 31, + "minLen": 6 + }, + "userName": { + "maxLen": 31, + "minLen": 1 + } + } + }, + "value": { + "User": [ + { + "level": "admin", + "userName": "admin" + } + ] + } + } +] diff --git a/examples/response/Login.json b/examples/response/Login.json new file mode 100644 index 0000000..5ce4120 --- /dev/null +++ b/examples/response/Login.json @@ -0,0 +1,7 @@ +[ + { + "cmd": "Login", + "code": 0, + "value": { "Token": { "leaseTime": 3600, "name": "xxxxxxxxxxxx" } } + } +] diff --git a/examples/response/Logout.json b/examples/response/Logout.json new file mode 100644 index 0000000..a0588b2 --- /dev/null +++ b/examples/response/Logout.json @@ -0,0 +1,7 @@ +[ + { + "cmd": "Logout", + "code": 0, + "value": { "rspCode": 200 } + } +] diff --git a/examples/response/PtzCheck.json b/examples/response/PtzCheck.json new file mode 100644 index 0000000..8274769 --- /dev/null +++ b/examples/response/PtzCheck.json @@ -0,0 +1 @@ +[{"cmd": "PtzCheck", "code": 0, "value": {"rspCode": 200}}] \ No newline at end of file diff --git a/examples/response/PtzCtrl.json b/examples/response/PtzCtrl.json new file mode 100644 index 0000000..fa8e1fb --- /dev/null +++ b/examples/response/PtzCtrl.json @@ -0,0 +1,9 @@ +[ + { + "cmd" : "PtzCtrl", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] \ No newline at end of file diff --git a/examples/response/Reboot.json b/examples/response/Reboot.json new file mode 100644 index 0000000..eb10195 --- /dev/null +++ b/examples/response/Reboot.json @@ -0,0 +1,9 @@ +[ + { + "cmd": "Reboot", + "code": 0, + "value": { + "rspCode": 200 + } + } +] diff --git a/examples/response/SetAdvImageSettings.json b/examples/response/SetAdvImageSettings.json new file mode 100644 index 0000000..780adf3 --- /dev/null +++ b/examples/response/SetAdvImageSettings.json @@ -0,0 +1,10 @@ +[ + { + "cmd" : "SetIsp", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] + \ No newline at end of file diff --git a/examples/response/SetImageSettings.json b/examples/response/SetImageSettings.json new file mode 100644 index 0000000..26ef93d --- /dev/null +++ b/examples/response/SetImageSettings.json @@ -0,0 +1,10 @@ +[ + { + "cmd" : "SetImage", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] + \ No newline at end of file diff --git a/examples/response/SetPtzPreset.json b/examples/response/SetPtzPreset.json new file mode 100644 index 0000000..84121bc --- /dev/null +++ b/examples/response/SetPtzPreset.json @@ -0,0 +1,10 @@ +[ + { + "cmd" : "SetPtzPreset", + "code" : 0, + "value" : { + "rspCode" : 200 + } + } + ] + \ No newline at end of file diff --git a/examples/stream_gui.py b/examples/stream_gui.py new file mode 100644 index 0000000..c174bf9 --- /dev/null +++ b/examples/stream_gui.py @@ -0,0 +1,175 @@ +import sys +import os +from configparser import RawConfigParser +from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QSlider +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget +from PyQt6.QtCore import Qt, QUrl, QTimer +from PyQt6.QtGui import QWheelEvent +from reolinkapi import Camera +from threading import Lock + +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def read_config(props_path: str) -> dict: + config = RawConfigParser() + assert os.path.exists(props_path), f"Path does not exist: {props_path}" + config.read(props_path) + return config + +class ZoomSlider(QSlider): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def keyPressEvent(self, event): + if event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): + event.ignore() + else: + super().keyPressEvent(event) + +class CameraPlayer(QWidget): + def __init__(self, rtsp_url_wide, rtsp_url_telephoto, camera: Camera): + super().__init__() + self.setWindowTitle("Reolink PTZ Streamer") + self.setGeometry(10, 10, 2400, 900) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + self.camera = camera + self.zoom_timer = QTimer(self) + self.zoom_timer.timeout.connect(self.stop_zoom) + + # Create media players + self.media_player_wide = QMediaPlayer() + self.media_player_telephoto = QMediaPlayer() + + # Create video widgets + self.video_widget_wide = QVideoWidget() + self.video_widget_telephoto = QVideoWidget() + self.media_player_wide.setVideoOutput(self.video_widget_wide) + self.media_player_telephoto.setVideoOutput(self.video_widget_telephoto) + self.video_widget_wide.wheelEvent = self.handle_wheel_event + self.video_widget_telephoto.wheelEvent = self.handle_wheel_event + + # Create layout + layout = QHBoxLayout() + layout.addWidget(self.video_widget_wide, 2) + layout.addWidget(self.video_widget_telephoto, 2) + self.setLayout(layout) + + # Start playing the streams + self.media_player_wide.setSource(QUrl(rtsp_url_wide)) + self.media_player_telephoto.setSource(QUrl(rtsp_url_telephoto)) + self.media_player_wide.play() + self.media_player_telephoto.play() + + + def keyPressEvent(self, event): + if event.isAutoRepeat(): + return + if event.key() == Qt.Key.Key_Escape: + self.close() + elif event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): + self.start_move(event.key()) + + def keyReleaseEvent(self, event): + if event.isAutoRepeat(): + return + if event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): + self.stop_move() + + def start_move(self, key): + direction = { + Qt.Key.Key_Left: "left", + Qt.Key.Key_Right: "right", + Qt.Key.Key_Up: "up", + Qt.Key.Key_Down: "down" + }.get(key) + + if direction: + self.move_camera(direction) + + def stop_move(self): + response = self.camera.stop_ptz() + print("Stop PTZ") + if response[0].get('code') != 0: + self.show_error_message("Failed to stop camera movement", str(response[0])) + + def move_camera(self, direction): + speed = 25 + if direction == "left": + response = self.camera.move_left(speed) + elif direction == "right": + response = self.camera.move_right(speed) + elif direction == "up": + response = self.camera.move_up(speed) + elif direction == "down": + response = self.camera.move_down(speed) + else: + print(f"Invalid direction: {direction}") + return + + if response[0].get('code') == 0: + print(f"Moving camera {direction}") + else: + self.show_error_message(f"Failed to move camera {direction}", str(response[0])) + + def handle_wheel_event(self, event: QWheelEvent): + delta = event.angleDelta().y() + if delta > 0: + self.zoom_in() + elif delta < 0: + self.zoom_out() + + def zoom_in(self): + self.start_zoom('in') + + def zoom_out(self): + self.start_zoom('out') + + def start_zoom(self, direction: str): + self.zoom_timer.stop() # Stop any ongoing zoom timer + speed = 60 # You can adjust this value as needed + if direction == 'in': + response = self.camera.start_zooming_in(speed) + else: + response = self.camera.start_zooming_out(speed) + + if response[0].get('code') == 0: + print(f"Zooming {direction}") + self.zoom_timer.start(200) # Stop zooming after 200ms + else: + self.show_error_message(f"Failed to start zooming {direction}", str(response[0])) + + def stop_zoom(self): + response = self.camera.stop_zooming() + if response[0].get('code') != 0: + self.show_error_message("Failed to stop zooming", str(response[0])) + + def show_error_message(self, title, message): + print(f"Error: {title} {message}") + + def handle_error(self, error): + print(f"Media player error: {error}") + + +if __name__ == '__main__': + # Read in your ip, username, & password from the configuration file + config = read_config('camera.cfg') + ip = config.get('camera', 'ip') + un = config.get('camera', 'username') + pw = config.get('camera', 'password') + + # Connect to camera + cam = Camera(ip, un, pw, https=True) + + rtsp_url_wide = f"rtsp://{un}:{pw}@{ip}/Preview_01_sub" + rtsp_url_telephoto = f"rtsp://{un}:{pw}@{ip}/Preview_02_sub" + + # Connect to camera + cam = Camera(ip, un, pw, https=True) + + app = QApplication(sys.argv) + player = CameraPlayer(rtsp_url_wide, rtsp_url_telephoto, cam) + player.show() + sys.exit(app.exec()) diff --git a/examples/streaming_video.py b/examples/streaming_video.py new file mode 100644 index 0000000..9049ed8 --- /dev/null +++ b/examples/streaming_video.py @@ -0,0 +1,81 @@ +import cv2 +from reolinkapi import Camera + + +def non_blocking(): + print("calling non-blocking") + + def inner_callback(img): + cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600)) + print("got the image non-blocking") + key = cv2.waitKey(1) + if key == ord('q'): + cv2.destroyAllWindows() + exit(1) + + c = Camera("192.168.1.112", "admin", "jUa2kUzi") + # t in this case is a thread + t = c.open_video_stream(callback=inner_callback) + + print(t.is_alive()) + while True: + if not t.is_alive(): + print("continuing") + break + # stop the stream + # client.stop_stream() + + +def blocking(): + c = Camera("192.168.1.112", "admin", "jUa2kUzi") + # stream in this case is a generator returning an image (in mat format) + stream = c.open_video_stream() + + # using next() + # while True: + # img = next(stream) + # cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600)) + # print("got the image blocking") + # key = cv2.waitKey(1) + # if key == ord('q'): + # cv2.destroyAllWindows() + # exit(1) + + # or using a for loop + for img in stream: + cv2.imshow("name", maintain_aspect_ratio_resize(img, width=600)) + print("got the image blocking") + key = cv2.waitKey(1) + if key == ord('q'): + cv2.destroyAllWindows() + exit(1) + + +# Resizes a image and maintains aspect ratio +def maintain_aspect_ratio_resize(image, width=None, height=None, inter=cv2.INTER_AREA): + # Grab the image size and initialize dimensions + dim = None + (h, w) = image.shape[:2] + + # Return original image if no need to resize + if width is None and height is None: + return image + + # We are resizing height if width is none + if width is None: + # Calculate the ratio of the height and construct the dimensions + r = height / float(h) + dim = (int(w * r), height) + # We are resizing width if height is none + else: + # Calculate the ratio of the 0idth and construct the dimensions + r = width / float(w) + dim = (width, int(h * r)) + + # Return the resized image + return cv2.resize(image, dim, interpolation=inter) + + +# Call the methods. Either Blocking (using generator) or Non-Blocking using threads +# non_blocking() +blocking() diff --git a/examples/video_review_gui.py b/examples/video_review_gui.py new file mode 100644 index 0000000..6327dca --- /dev/null +++ b/examples/video_review_gui.py @@ -0,0 +1,657 @@ +# Video review GUI +# https://github.com/sven337/ReolinkLinux/wiki#reolink-video-review-gui + +import os +import signal +import sys +import re +import datetime +import subprocess +import argparse +from configparser import RawConfigParser +from datetime import datetime as dt, timedelta +from reolinkapi import Camera +from PyQt6.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QFileDialog, QHeaderView, QStyle, QSlider, QStyleOptionSlider, QSplitter, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget +from PyQt6.QtCore import Qt, QUrl, QTimer, QThread, pyqtSignal, QMutex, QWaitCondition +from PyQt6.QtGui import QColor, QBrush, QFont, QIcon +from collections import deque + +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def path_name_from_camera_path(fname): + # Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 + return fname.replace('/', '_') + +# Function to decode hex values into individual flags +def decode_hex_to_flags(hex_value): + flags_mapping = { + 'resolution_index': (21, 7), + 'tv_system': (20, 1), + 'framerate': (13, 7), + 'audio_index': (11, 2), + 'ai_pd': (10, 1), # person + 'ai_fd': (9, 1), # face + 'ai_vd': (8, 1), # vehicle + 'ai_ad': (7, 1), # animal + 'encoder_type_index': (5, 2), + 'is_schedule_record': (4, 1), #scheduled + 'is_motion_record': (3, 1), # motion detected + 'is_rf_record': (2, 1), + 'is_doorbell_press_record': (1, 1), + 'ai_other': (0, 1) + } + hex_value = int(hex_value, 16) # Convert hex string to integer + flag_values = {} + for flag, (bit_position, bit_size) in flags_mapping.items(): + mask = ((1 << bit_size) - 1) << bit_position + flag_values[flag] = (hex_value & mask) >> bit_position + return flag_values + +def parse_filename(file_name): + # Mp4Record_2024-08-12_RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 + # Mp4Record_2024-09-13-RecS09_DST20240907_084519_084612_0_55289080000000_307BC0.mp4 + # https://github.com/sven337/ReolinkLinux/wiki/Figuring-out-the-file-names#file-name-structure + pattern = r'.*?Mp4Record_(\d{4}-\d{2}-\d{2})_Rec[MS](\d)(\d)_(DST)?(\d{8})_(\d{6})_(\d{6})' + v3_suffix = r'.*_(\w{4,8})_(\w{4,8})\.mp4' + v9_suffix = r'.*_(\d)_(\w{7})(\w{7})_(\w{1,8})\.mp4' + match = re.match(pattern, file_name) + + out = {} + version = 0 + + if match: + date = match.group(1) # YYYY-MM-DD + channel = int(match.group(2)) + version = int(match.group(3)) # version + start_date = match.group(5) # YYYYMMDD + start_time = match.group(6) # HHMMSS + end_time = match.group(7) # HHMMSS + + # Combine date and start time into a datetime object + start_datetime = datetime.datetime.strptime(f"{start_date} {start_time}", "%Y%m%d %H%M%S") + + out = {'start_datetime': start_datetime, 'channel': channel, 'end_time': end_time } + else: + print("parse error") + return None + + if version == 9: + match = re.match(v9_suffix, file_name) + if not match: + print(f"v9 parse error for {file_name}") + return None + + animal_type = match.group(1) + flags_hex1 = match.group(2) + flags_hex2 = match.group(3) + file_size = int(match.group(4), 16) + + triggers = decode_hex_to_flags(flags_hex1) + + out.update({'animal_type' : animal_type, 'file_size' : file_size, 'triggers' : triggers }) + + elif version == 2 or version == 3: + match = re.match(v3_suffix, file_name) + if not match: + print(f"v3 parse error for {file_name}") + return None + + flags_hex = match.group(1) + file_size = int(match.group(2), 16) + + triggers = decode_hex_to_flags(flags_hex) + + out.update({'file_size' : file_size, 'triggers' : triggers }) + + return out + +class ClickableSlider(QSlider): + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + val = self.pixelPosToRangeValue(event.pos()) + self.setValue(val) + super().mousePressEvent(event) + + def pixelPosToRangeValue(self, pos): + opt = QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderGroove, self) + sr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self) + + if self.orientation() == Qt.Orientation.Horizontal: + sliderLength = sr.width() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + pos = pos.x() + else: + sliderLength = sr.height() + sliderMin = gr.y() + sliderMax = gr.bottom() - sliderLength + 1 + pos = pos.y() + + return QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos - sliderMin, sliderMax - sliderMin, opt.upsideDown) + +class DownloadThread(QThread): + download_complete = pyqtSignal(str, bool) + download_start = pyqtSignal(str) + + def __init__(self, download_queue, cam): + super().__init__() + self.download_queue = download_queue + self.cam = cam + self.mutex = QMutex() + self.wait_condition = QWaitCondition() + self.is_running = True + + def run(self): + while self.is_running: + self.mutex.lock() + if len(self.download_queue) == 0: + self.wait_condition.wait(self.mutex) + self.mutex.unlock() + + if not self.is_running: + break + + try: + fname, output_path = self.download_queue.popleft() + output_path = os.path.join(video_storage_dir, output_path) + print(f"Downloading: {fname}") + self.download_start.emit(output_path) + resp = self.cam.get_file(fname, output_path=output_path) + if resp: + print(f"Download complete: {output_path}") + self.download_complete.emit(output_path, True) + else: + print(f"Download failed: {fname}") + self.download_complete.emit(output_path, False) + except IndexError: + pass + + def stop(self): + self.mutex.lock() + self.is_running = False + self.mutex.unlock() + self.wait_condition.wakeAll() + + def add_to_queue(self, fname, output_path, left=False): + self.mutex.lock() + if left: + self.download_queue.appendleft((fname, output_path)) + else: + self.download_queue.append((fname, output_path)) + self.wait_condition.wakeOne() + self.mutex.unlock() + + +class VideoPlayer(QWidget): + file_exists_signal = pyqtSignal(str, bool) + + def __init__(self, video_files): + super().__init__() + self.setWindowTitle("Reolink Video Review GUI") + self.cam = cam + self.download_queue = deque() + self.download_thread = DownloadThread(self.download_queue, self.cam) + self.download_thread.download_start.connect(self.on_download_start) + self.download_thread.download_complete.connect(self.on_download_complete) + self.download_thread.start() + + # Create media player + self.media_player = QMediaPlayer() + self.media_player.errorOccurred.connect(self.handle_media_player_error) + + # Create video widget + self.video_widget = QVideoWidget() + self.media_player.setVideoOutput(self.video_widget) + self.media_player.setPlaybackRate(1.5) + + # Create table widget to display video files + self.video_tree = QTreeWidget() + self.video_tree.setColumnCount(10) + self.video_tree.setHeaderLabels(["Status", "Video Path", "Start Datetime", "End Time", "Channel", "Person", "Vehicle", "Pet", "Motion", "Timer"]) + self.video_tree.setSortingEnabled(True) + self.video_tree.itemClicked.connect(self.play_video) + + # Set smaller default column widths + self.video_tree.setColumnWidth(0, 35) # Status + self.video_tree.setColumnWidth(1, 120) # Video Path + self.video_tree.setColumnWidth(2, 130) # Start Datetime + self.video_tree.setColumnWidth(3, 70) # End Time + self.video_tree.setColumnWidth(4, 35) # Channel + self.video_tree.setColumnWidth(5, 35) # Person + self.video_tree.setColumnWidth(6, 35) # Vehicle + self.video_tree.setColumnWidth(7, 35) # Pet + self.video_tree.setColumnWidth(8, 35) # Motion + self.video_tree.setColumnWidth(9, 30) # Timer + + self.video_tree.setIndentation(10) + + QIcon.setThemeName("Adwaita") + + # Create open button to select video files + self.open_button = QPushButton("Open Videos") + self.open_button.clicked.connect(self.open_videos) + + self.play_button = QPushButton() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self.play_button.clicked.connect(self.play_pause) + + self.mpv_button = QPushButton("MPV") + self.mpv_button.clicked.connect(self.open_in_mpv) + + self.get_highres_button = QPushButton("GetHighRes") + self.get_highres_button.clicked.connect(self.get_highres_stream_for_file) + self.get_highres_button.setEnabled(False) # Disable by default + + # Create seek slider + self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal) + self.seek_slider.setRange(0, 0) + self.seek_slider.sliderPressed.connect(self.seek_slider_pressed) + self.seek_slider.sliderReleased.connect(self.seek_slider_released) + self.seek_slider.sliderMoved.connect(self.seek_slider_moved) + self.media_player.positionChanged.connect(self.update_position) + self.media_player.durationChanged.connect(self.update_duration) + + # Create playback speed slider + self.speed_slider = QSlider(Qt.Orientation.Horizontal) + self.speed_slider.setRange(50, 300) + self.speed_slider.setValue(200) + self.speed_slider.valueChanged.connect(self.set_speed) + speed_label = QLabel("Speed: 2.0x") + self.speed_slider.valueChanged.connect(lambda v: speed_label.setText(f"Speed: {v/100:.1f}x")) + + main_layout = QHBoxLayout() + # Create a splitter + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left side (table and open button) + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.addWidget(self.video_tree) + left_layout.addWidget(self.open_button) + splitter.addWidget(left_widget) + + # Right side (video player and controls) + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.addWidget(self.video_widget, 1) + + controls_widget = QWidget() + controls_layout = QVBoxLayout(controls_widget) + + control_layout = QHBoxLayout() + control_layout.addWidget(self.play_button) + control_layout.addWidget(self.seek_slider) + control_layout.addWidget(self.mpv_button) + control_layout.addWidget(self.get_highres_button) + controls_layout.addLayout(control_layout) + + speed_layout = QHBoxLayout() + speed_layout.addWidget(QLabel("Speed:")) + speed_layout.addWidget(self.speed_slider) + speed_layout.addWidget(speed_label) + controls_layout.addLayout(speed_layout) + right_layout.addWidget(controls_widget) + splitter.addWidget(right_widget) + + # Set initial sizes + splitter.setSizes([300, 700]) + + main_layout.addWidget(splitter) + self.setLayout(main_layout) + self.file_exists_signal.connect(self.on_download_complete) + + self.add_initial_videos(video_files) + + def add_initial_videos(self, video_files): + for video_path in video_files: + self.add_video(video_path) + self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder) +# self.video_tree.expandAll() + + def open_videos(self): + file_dialog = QFileDialog(self) + file_dialog.setNameFilters(["Videos (*.mp4 *.avi *.mov)"]) + file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles) + if file_dialog.exec(): + self.video_tree.setSortingEnabled(False) + for file in file_dialog.selectedFiles(): + self.add_video(os.path.basename(file)) + self.video_tree.setSortingEnabled(True) + self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder) +# self.video_tree.expandAll() + + def add_video(self, video_path): + # We are passed the camera file name, e.g. Mp4Record/2024-08-12/RecM13_DST20240812_214255_214348_1F1E828_4DDA4D.mp4 + file_path = path_name_from_camera_path(video_path) + base_file_name = file_path + parsed_data = parse_filename(file_path) + if not parsed_data: + print(f"Could not parse file {video_path}") + return + + start_datetime = parsed_data['start_datetime'] + channel = parsed_data['channel'] + + end_time = datetime.datetime.strptime(parsed_data['end_time'], "%H%M%S") + end_time_str = end_time.strftime("%H:%M:%S") + + video_item = QTreeWidgetItem() + video_item.setText(0, "") # Status + video_item.setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) + video_item.setText(1, base_file_name) + video_item.setText(2, start_datetime.strftime("%Y-%m-%d %H:%M:%S")) + video_item.setData(2, Qt.ItemDataRole.UserRole, parsed_data['start_datetime']) + video_item.setText(3, end_time_str) + video_item.setText(4, str(channel)) + video_item.setText(5, "✓" if parsed_data['triggers']['ai_pd'] else "") + video_item.setText(6, "✓" if parsed_data['triggers']['ai_vd'] else "") + video_item.setText(7, "✓" if parsed_data['triggers']['ai_ad'] else "") + video_item.setText(8, "✓" if parsed_data['triggers']['is_motion_record'] else "") + video_item.setText(9, "✓" if parsed_data['triggers']['is_schedule_record'] else "") + + if parsed_data['triggers']['ai_other']: + print(f"File {file_path} has ai_other flag!") + + video_item.setToolTip(1, base_file_name) + # Set the style for queued status + grey_color = QColor(200, 200, 200) # Light grey + video_item.setForeground(1, QBrush(grey_color)) + font = QFont() + font.setItalic(True) + video_item.setFont(1, font) + + # Make the fields non-editable + iterator = QTreeWidgetItemIterator(self.video_tree) + while iterator.value(): + item = iterator.value() + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + iterator += 1 + + # Find a potentially pre-existing channel0 item for this datetime, if so, add as a child + # This lets channel1 appear as a child, but also main & sub videos appear in the same group + channel_0_item = self.find_channel_0_item(start_datetime) + + if channel_0_item: + # Check if the current item is a main stream and the existing channel_0_item is a sub stream + if "RecM" in base_file_name and "RecS" in channel_0_item.text(1): + # Make the current main stream item the new parent + new_parent = video_item + # Move all children of the sub stream item to the new main stream item + while channel_0_item.childCount() > 0: + child = channel_0_item.takeChild(0) + new_parent.addChild(child) + # Remove the old sub stream item + parent = channel_0_item.parent() + if parent: + parent.removeChild(channel_0_item) + else: + index = self.video_tree.indexOfTopLevelItem(channel_0_item) + self.video_tree.takeTopLevelItem(index) + # Add the new main stream item as a top-level item + self.video_tree.addTopLevelItem(new_parent) + else: + # If it's not a main stream replacing a sub stream, add as a child as before + channel_0_item.addChild(video_item) + else: + self.video_tree.addTopLevelItem(video_item) + + output_path = os.path.join(video_storage_dir, base_file_name) + expected_size = parsed_data['file_size'] + + need_download = True + if os.path.isfile(output_path): + actual_size = os.path.getsize(output_path) + if actual_size == expected_size: + need_download = False + self.file_exists_signal.emit(output_path, True) + else: + print(f"File size mismatch for {output_path}. Expected: {expected_size}, Actual: {actual_size}. Downloading again") + + if need_download: + video_item.setIcon(1, self.style().standardIcon(QStyle.StandardPixmap.SP_CommandLink)) + self.download_thread.add_to_queue(video_path, base_file_name) + + def find_channel_0_item(self, datetime_obj): + # Truncate seconds to nearest 10 + truncated_seconds = datetime_obj.second - (datetime_obj.second % 10) + truncated_datetime = datetime_obj.replace(second=truncated_seconds) + + for i in range(self.video_tree.topLevelItemCount()): + item = self.video_tree.topLevelItem(i) + item_datetime = item.data(2, Qt.ItemDataRole.UserRole) + item_truncated = item_datetime.replace(second=item_datetime.second - (item_datetime.second % 10)) + if item_truncated == truncated_datetime: + return item + return None + + def find_item_by_path(self, path): + iterator = QTreeWidgetItemIterator(self.video_tree) + while iterator.value(): + item = iterator.value() + text = item.text(1) + if text == path: + return item + iterator += 1 + print(f"Could not find item by path {path}") + return None + + def on_download_complete(self, video_path, success): + item = self.find_item_by_path(os.path.basename(video_path)) + if not item: + print(f"on_download_complete {video_path} did not find item?!") + return + item.setForeground(1, QBrush(QColor(0, 0, 0))) # Black color for normal text + font = item.font(1) + font.setItalic(False) + font.setBold(False) + item.setFont(1, font) + if success: + if "RecS" in item.text(1): + # One day (hopefully) offer the option to download the + # high-res version This is not trivial because we have to + # re-do a camera search for the relevant time period and + # match based on start and end dates (+/- one second in my + # experience) + # For now simply display that this is low-res. + item.setText(0, "sub") + item.setIcon(1, QIcon()) + else: + item.setIcon(1, self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical)) + + + def on_download_start(self, video_path): + item = self.find_item_by_path(os.path.basename(video_path)) + if item: + grey_color = QColor(200, 200, 200) # Light grey + item.setForeground(1, QBrush(grey_color)) + font = item.font(1) + font.setBold(True) + item.setFont(1, font) + item.setIcon(1, QIcon.fromTheme("emblem-synchronizing")) + else: + print(f"Cannot find item for {video_path}") + + def play_video(self, file_name_item, column): + video_path = os.path.join(video_storage_dir, file_name_item.text(1)) + + if file_name_item.font(1).italic() or file_name_item.foreground(1).color().lightness() >= 200: + print(f"Video {video_path} is not yet downloaded. Moving it to top of queue. Please wait for download.") + # Find the item in the download_queue that matches the base file name + found_item = None + for item in list(self.download_queue): + if item[1] == file_name_item.text(1): + found_item = item + break + + if found_item: + # Remove the item from its current position in the queue + self.download_queue.remove(found_item) + # Add the item to the end of the queue + self.download_thread.add_to_queue(*found_item, left=True) + return + + # Enable/disable GetHighRes button based on whether it's a sub stream + self.get_highres_button.setEnabled("RecS" in file_name_item.text(1)) + + print(f"Playing video: {video_path}") + url = QUrl.fromLocalFile(video_path) + self.media_player.setSource(url) + self.video_widget.show() + def start_playback(): + # Seek to 5 seconds (pre-record) + self.media_player.setPosition(5000) + self.media_player.play() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause)) + print(f"Media player state: {self.media_player.playbackState()}") + + # Timer needed to be able to play at seek offset in the video, otherwise setPosition seems ignored + QTimer.singleShot(20, start_playback) + + def get_highres_stream_for_file(self): + current_item = self.video_tree.currentItem() + if not current_item or "RecS" not in current_item.text(1): + return + + parsed_data = parse_filename(current_item.text(1)) + if not parsed_data: + print(f"Could not parse file {current_item.text(1)}") + return + + start_time = parsed_data['start_datetime'] - timedelta(seconds=1) + end_time = datetime.datetime.strptime(f"{parsed_data['start_datetime'].strftime('%Y%m%d')} {parsed_data['end_time']}", "%Y%m%d %H%M%S") + timedelta(seconds=1) + + main_files = self.cam.get_motion_files(start=start_time, end=end_time, streamtype='main', channel=parsed_data['channel']) + + if main_files: + for main_file in main_files: + self.add_video(main_file['filename']) + self.video_tree.sortItems(2, Qt.SortOrder.DescendingOrder) + else: + print(f"No main stream file found for {current_item.text(1)}") + + def play_pause(self): + if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.media_player.pause() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + else: + self.media_player.play() + self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause)) + + def handle_media_player_error(self, error): + print(f"Media player error: {error}") + + def seek_slider_pressed(self): + self.media_player.setPosition(self.seek_slider.value()) + self.media_player.play() + + def seek_slider_released(self): + self.media_player.setPosition(self.seek_slider.value()) + self.media_player.play() + + def seek_slider_moved(self, position): + # Update video frame while dragging + self.media_player.setPosition(position) + + def update_position(self, position): + self.seek_slider.setValue(position) + + def update_duration(self, duration): + self.seek_slider.setRange(0, duration) + + def set_speed(self, speed): + self.media_player.setPlaybackRate(speed / 100) + + def open_in_mpv(self): + current_video = self.media_player.source().toString() + if current_video: + # Remove the 'file://' prefix if present + video_path = current_video.replace('file://', '') + try: + subprocess.Popen(['mpv', video_path]) + except FileNotFoundError: + print("Error: MPV player not found. Make sure it's installed and in your system PATH.") + else: + print("No video is currently selected.") + + def closeEvent(self, event): + self.download_thread.stop() + self.download_thread.wait(1000) + self.download_thread.terminate() + self.cam.logout() + super().closeEvent(event) + +def read_config(props_path: str) -> dict: + """Reads in a properties file into variables. + + NB! this config file is kept out of commits with .gitignore. The structure of this file is such: + # secrets.cfg + [camera] + ip={ip_address} + username={username} + password={password} + """ + config = RawConfigParser() + assert os.path.exists(props_path), f"Path does not exist: {props_path}" + config.read(props_path) + return config + + +def signal_handler(sig, frame): + print("Exiting the application...") + sys.exit(0) + cam.logout() + QApplication.quit() + + + +if __name__ == '__main__': + signal.signal(signal.SIGINT, signal_handler) + + parser = argparse.ArgumentParser(description="Reolink Video Review GUI") + parser.add_argument('--main', action='https://github.com/store_true', help="Search for main channel instead of sub channel") + parser.add_argument('files', nargs='*', help="Optional video file names to process") + args = parser.parse_args() + + config = read_config('camera.cfg') + + ip = config.get('camera', 'ip') + un = config.get('camera', 'username') + pw = config.get('camera', 'password') + video_storage_dir = config.get('camera', 'video_storage_dir') + +# Connect to camera + cam = Camera(ip, un, pw, https=True) + + start = dt.combine(dt.now(), dt.min.time()) + end = dt.now() + + streamtype = 'sub' if not args.main else 'main' + processed_motions = cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=0) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=1) + + start = dt.now() - timedelta(days=1) + end = dt.combine(start, dt.max.time()) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=0) + processed_motions += cam.get_motion_files(start=start, end=end, streamtype=streamtype, channel=1) + + + if len(processed_motions) == 0: + print("Camera did not return any video?!") + + video_files = [] + for i, motion in enumerate(processed_motions): + fname = motion['filename'] + print("Processing %s" % (fname)) + video_files.append(fname) + + video_files.extend([os.path.basename(file) for file in args.files]) + app = QApplication(sys.argv) + player = VideoPlayer(video_files) + player.resize(2400, 1000) + player.show() + sys.exit(app.exec()) diff --git a/make-and-publish-package.sh b/make-and-publish-package.sh new file mode 100755 index 0000000..bf414e6 --- /dev/null +++ b/make-and-publish-package.sh @@ -0,0 +1,3 @@ +rm -rf dist +python setup.py sdist bdist_wheel +twine upload dist/* \ No newline at end of file diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py new file mode 100644 index 0000000..ce35f76 --- /dev/null +++ b/reolinkapi/__init__.py @@ -0,0 +1,4 @@ +from reolinkapi.handlers.api_handler import APIHandler +from .camera import Camera + +__version__ = "0.4.1" diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py new file mode 100644 index 0000000..7371b20 --- /dev/null +++ b/reolinkapi/camera.py @@ -0,0 +1,43 @@ +from reolinkapi.handlers.api_handler import APIHandler + + +class Camera(APIHandler): + + def __init__(self, ip: str, + username: str = "admin", + password: str = "", + https: bool = False, + defer_login: bool = False, + profile: str = "main", + **kwargs): + """ + Initialise the Camera object by passing the ip address. + The default details {"username":"admin", "password":""} will be used if nothing passed + For deferring the login to the camera, just pass defer_login = True. + For connecting to the camera behind a proxy pass a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"} + :param ip: + :param username: + :param password: + :param https: connect to the camera over https + :param defer_login: defer the login process + :param proxy: Add a proxy dict for requests to consume. + eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...} + More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679 + """ + if profile not in ["main", "sub"]: + raise Exception("Profile argument must be either \"main\" or \"sub\"") + + # For when you need to connect to a camera behind a proxy, pass + # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"} + APIHandler.__init__(self, ip, username, password, https=https, **kwargs) + + # Normal call without proxy: + # APIHandler.__init__(self, ip, username, password) + + self.ip = ip + self.username = username + self.password = password + self.profile = profile + + if not defer_login: + super().login() diff --git a/reolinkapi/handlers/__init__.py b/reolinkapi/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/handlers/api_handler.py b/reolinkapi/handlers/api_handler.py new file mode 100644 index 0000000..2ef7464 --- /dev/null +++ b/reolinkapi/handlers/api_handler.py @@ -0,0 +1,144 @@ +import requests +from typing import Dict, List, Optional, Union +from reolinkapi.mixins.alarm import AlarmAPIMixin +from reolinkapi.mixins.device import DeviceAPIMixin +from reolinkapi.mixins.display import DisplayAPIMixin +from reolinkapi.mixins.download import DownloadAPIMixin +from reolinkapi.mixins.image import ImageAPIMixin +from reolinkapi.mixins.motion import MotionAPIMixin +from reolinkapi.mixins.network import NetworkAPIMixin +from reolinkapi.mixins.ptz import PtzAPIMixin +from reolinkapi.mixins.record import RecordAPIMixin +from reolinkapi.handlers.rest_handler import Request +from reolinkapi.mixins.stream import StreamAPIMixin +from reolinkapi.mixins.system import SystemAPIMixin +from reolinkapi.mixins.user import UserAPIMixin +from reolinkapi.mixins.zoom import ZoomAPIMixin +from reolinkapi.mixins.nvrdownload import NvrDownloadAPIMixin + + +class APIHandler(AlarmAPIMixin, + DeviceAPIMixin, + DisplayAPIMixin, + DownloadAPIMixin, + ImageAPIMixin, + MotionAPIMixin, + NetworkAPIMixin, + PtzAPIMixin, + RecordAPIMixin, + SystemAPIMixin, + UserAPIMixin, + ZoomAPIMixin, + StreamAPIMixin, + NvrDownloadAPIMixin): + """ + The APIHandler class is the backend part of the API, the actual API calls + are implemented in Mixins. + This handles communication directly with the camera. + Current camera's tested: RLC-411WS + + All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/ + """ + + def __init__(self, ip: str, username: str, password: str, https: bool = False, **kwargs): + """ + Initialise the Camera API Handler (maps api calls into python) + :param ip: + :param username: + :param password: + :param https: connect over https + :param proxy: Add a proxy dict for requests to consume. + eg: {"http":"socks5://[username]:[password]@[host]:[port], "https": ...} + More information on proxies in requests: https://stackoverflow.com/a/15661226/9313679 + """ + scheme = 'https' if https else 'http' + self.url = f"{scheme}://{ip}/cgi-bin/api.cgi" + self.ip = ip + self.token = None + self.username = username + self.password = password + Request.proxies = kwargs.get("proxy") # Defaults to None if key isn't found + Request.session = kwargs.get("session") # Defaults to None if key isn't found + + def login(self) -> bool: + """ + Get login token + Must be called first, before any other operation can be performed + :return: bool + """ + try: + body = [{"cmd": "Login", "action": 0, + "param": {"User": {"userName": self.username, "password": self.password}}}] + param = {"cmd": "Login", "token": "null"} + response = Request.post(self.url, data=body, params=param) + if response is not None: + data = response.json()[0] + code = data["code"] + if int(code) == 0: + self.token = data["value"]["Token"]["name"] + print("Login success") + return True + print(self.token) + return False + else: + # TODO: Verify this change w/ owner. Delete old code if acceptable. + # A this point, response is NoneType. There won't be a status code property. + # print("Failed to login\nStatus Code:", response.status_code) + print("Failed to login\nResponse was null.") + return False + except Exception as e: + print("Error Login\n", e) + raise + + def logout(self) -> bool: + """ + Logout of the camera + :return: bool + """ + try: + data = [{"cmd": "Logout", "action": 0}] + self._execute_command('Logout', data) + # print(ret) + return True + except Exception as e: + print("Error Logout\n", e) + return False + + def _execute_command(self, command: str, data: List[Dict], multi: bool = False) -> \ + Optional[Union[Dict, bool]]: + """ + Send a POST request to the IP camera with given data. + :param command: name of the command to send + :param data: object to send to the camera (send as json) + :param multi: whether the given command name should be added to the + url parameters of the request. Defaults to False. (Some multi-step + commands seem to not have a single command name) + :return: response JSON as python object + """ + params = {"token": self.token, 'cmd': command} + if multi: + del params['cmd'] + try: + if self.token is None: + raise ValueError("Login first") + if command == 'Download' or command == 'Playback': + # Special handling for downloading an mp4 + # Pop the filepath from data + tgt_filepath = data[0].pop('filepath') + # Apply the data to the params + params.update(data[0]) + with requests.get(self.url, params=params, stream=True, verify=False, timeout=(1, None), proxies=Request.proxies) as req: + if req.status_code == 200: + with open(tgt_filepath, 'wb') as f: + f.write(req.content) + return True + else: + print(f'Error received: {req.status_code}') + return False + + else: + response = Request.post(self.url, data=data, params=params) + return response.json() + except Exception as e: + print(f"Command {command} failed: {e}") + raise diff --git a/reolinkapi/handlers/rest_handler.py b/reolinkapi/handlers/rest_handler.py new file mode 100644 index 0000000..a9512f4 --- /dev/null +++ b/reolinkapi/handlers/rest_handler.py @@ -0,0 +1,52 @@ +import requests +from typing import List, Dict, Union, Optional + + +class Request: + proxies = None + session = None + + @staticmethod + def __getSession(): + reqHandler = requests + if Request.session is not None: + reqHandler = Request.session + return reqHandler + + @staticmethod + def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \ + Optional[requests.Response]: + """ + Post request + :param params: + :param url: + :param data: + :return: + """ + try: + headers = {'content-type': 'application/json'} + r = Request.__getSession().post(url, verify=False, params=params, json=data, headers=headers, + proxies=Request.proxies) + if r.status_code == 200: + return r + else: + raise ValueError(f"Http Request had non-200 Status: {r.status_code}", r.status_code) + except Exception as e: + print("Post Error\n", e) + raise + + @staticmethod + def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> Optional[requests.Response]: + """ + Get request + :param url: + :param params: + :param timeout: + :return: + """ + try: + data = Request.__getSession().get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) + return data + except Exception as e: + print("Get Error\n", e) + raise diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/mixins/alarm.py b/reolinkapi/mixins/alarm.py new file mode 100644 index 0000000..53bc6ee --- /dev/null +++ b/reolinkapi/mixins/alarm.py @@ -0,0 +1,14 @@ +from typing import Dict + + +class AlarmAPIMixin: + """API calls for getting device alarm information.""" + + def get_alarm_motion(self) -> Dict: + """ + Gets the device alarm motion + See examples/response/GetAlarmMotion.json for example response data. + :return: response json + """ + body = [{"cmd": "GetAlarm", "action": 1, "param": {"Alarm": {"channel": 0, "type": "md"}}}] + return self._execute_command('GetAlarm', body) diff --git a/reolinkapi/mixins/device.py b/reolinkapi/mixins/device.py new file mode 100644 index 0000000..21b7961 --- /dev/null +++ b/reolinkapi/mixins/device.py @@ -0,0 +1,49 @@ +from typing import List, Dict + + +class DeviceAPIMixin: + """API calls for getting device information.""" + DEFAULT_HDD_ID = [0] + + def set_device_name(self, name: str) -> bool: + """ + Set the device name of the camera. + :param name: The new name for the device + :return: bool indicating success + """ + body = [{"cmd": "SetDevName", "action": 0, "param": {"DevName": {"name": name}}}] + self._execute_command('SetDevName', body) + print(f"Successfully set device name to: {name}") + return True + + def get_device_name(self) -> Dict: + """ + Get the device name of the camera. + :return: Dict containing the device name + """ + body = [{"cmd": "GetDevName", "action": 0, "param": {}}] + return self._execute_command('GetDevName', body) + + def get_hdd_info(self) -> Dict: + """ + Gets all HDD and SD card information from Camera + See examples/response/GetHddInfo.json for example response data. + :return: response json + """ + body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] + return self._execute_command('GetHddInfo', body) + + def format_hdd(self, hdd_id: List[float] = None) -> bool: + """ + Format specified HDD/SD cards with their id's + :param hdd_id: List of id's specified by the camera with get_hdd_info api. Default is 0 (SD card) + :return: bool + """ + if hdd_id is None: + hdd_id = self.DEFAULT_HDD_ID + body = [{"cmd": "Format", "action": 0, "param": {"HddInfo": {"id": hdd_id}}}] + r_data = self._execute_command('Format', body)[0] + if r_data["value"]["rspCode"] == 200: + return True + print("Could not format HDD/SD. Camera responded with:", r_data["value"]) + return False diff --git a/reolinkapi/mixins/display.py b/reolinkapi/mixins/display.py new file mode 100644 index 0000000..c44c04b --- /dev/null +++ b/reolinkapi/mixins/display.py @@ -0,0 +1,57 @@ +from typing import Dict + + +class DisplayAPIMixin: + """API calls related to the current image (osd, on screen display).""" + + def get_osd(self) -> Dict: + """ + Get OSD information. + See examples/response/GetOsd.json for example response data. + :return: response json + """ + body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetOsd', body) + + def get_mask(self) -> Dict: + """ + Get the camera mask information. + See examples/response/GetMask.json for example response data. + :return: response json + """ + body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetMask', body) + + def set_osd(self, bg_color: bool = 0, channel: float = 0, osd_channel_enabled: bool = 0, + osd_channel_name: str = "", osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0, + osd_time_pos: str = "Lower Right", osd_watermark_enabled: bool = 0) -> bool: + """ + Set OSD + :param bg_color: bool + :param channel: int channel id + :param osd_channel_enabled: bool + :param osd_channel_name: string channel name + :param osd_channel_pos: string channel position + ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] + :param osd_time_enabled: bool + :param osd_time_pos: string time position + ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] + :return: whether the action was successful + """ + body = [{"cmd": "SetOsd", "action": 1, + "param": { + "Osd": { + "bgcolor": bg_color, + "channel": channel, + "osdChannel": { + "enable": osd_channel_enabled, "name": osd_channel_name, + "pos": osd_channel_pos + }, + "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos}, + "watermark": osd_watermark_enabled, + }}}] + r_data = self._execute_command('SetOsd', body)[0] + if 'value' in r_data and r_data["value"]["rspCode"] == 200: + return True + print("Could not set OSD. Camera responded with status:", r_data["error"]) + return False diff --git a/reolinkapi/mixins/download.py b/reolinkapi/mixins/download.py new file mode 100644 index 0000000..3580930 --- /dev/null +++ b/reolinkapi/mixins/download.py @@ -0,0 +1,21 @@ +class DownloadAPIMixin: + """API calls for downloading video files.""" + def get_file(self, filename: str, output_path: str, method = 'Playback') -> bool: + """ + Download the selected video file + On at least Trackmix Wifi, it was observed that the Playback method + yields much improved download speeds over the Download method, for + unknown reasons. + :return: response json + """ + body = [ + { + "cmd": method, + "source": filename, + "output": filename, + "filepath": output_path + } + ] + resp = self._execute_command(method, body) + + return resp diff --git a/reolinkapi/mixins/image.py b/reolinkapi/mixins/image.py new file mode 100644 index 0000000..8e30568 --- /dev/null +++ b/reolinkapi/mixins/image.py @@ -0,0 +1,103 @@ +from typing import Dict + + +class ImageAPIMixin: + """API calls for image settings.""" + + def set_adv_image_settings(self, + anti_flicker: str = 'Outdoor', + exposure: str = 'Auto', + gain_min: float = 1, + gain_max: float = 62, + shutter_min: float = 1, + shutter_max: float = 125, + blue_gain: float = 128, + red_gain: float = 128, + white_balance: str = 'Auto', + day_night: str = 'Auto', + back_light: str = 'DynamicRangeControl', + blc: float = 128, + drc: float = 128, + rotation: float = 0, + mirroring: float = 0, + nr3d: float = 1) -> Dict: + """ + Sets the advanced camera settings. + + :param anti_flicker: string + :param exposure: string + :param gain_min: int + :param gain_max: string + :param shutter_min: int + :param shutter_max: int + :param blue_gain: int + :param red_gain: int + :param white_balance: string + :param day_night: string + :param back_light: string + :param blc: int + :param drc: int + :param rotation: int + :param mirroring: int + :param nr3d: int + :return: response + """ + body = [{ + "cmd": "SetIsp", + "action": 0, + "param": { + "Isp": { + "channel": 0, + "antiFlicker": anti_flicker, + "exposure": exposure, + "gain": {"min": gain_min, "max": gain_max}, + "shutter": {"min": shutter_min, "max": shutter_max}, + "blueGain": blue_gain, + "redGain": red_gain, + "whiteBalance": white_balance, + "dayNight": day_night, + "backLight": back_light, + "blc": blc, + "drc": drc, + "rotation": rotation, + "mirroring": mirroring, + "nr3d": nr3d + } + } + }] + return self._execute_command('SetIsp', body) + + def set_image_settings(self, + brightness: float = 128, + contrast: float = 62, + hue: float = 1, + saturation: float = 125, + sharpness: float = 128) -> Dict: + """ + Sets the camera image settings. + + :param brightness: int + :param contrast: string + :param hue: int + :param saturation: int + :param sharpness: int + :return: response + """ + body = [ + { + "cmd": "SetImage", + "action": 0, + "param": { + "Image": { + "bright": brightness, + "channel": 0, + "contrast": contrast, + "hue": hue, + "saturation": saturation, + "sharpen": sharpness + } + } + } + ] + + return self._execute_command('SetImage', body) diff --git a/reolinkapi/mixins/motion.py b/reolinkapi/mixins/motion.py new file mode 100644 index 0000000..6aada63 --- /dev/null +++ b/reolinkapi/mixins/motion.py @@ -0,0 +1,85 @@ +from typing import Union, List, Dict +from datetime import datetime as dt + + +# Type hints for input and output of the motion api response +RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, float, Dict[str, str]]]] +PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, dt]]] + + +class MotionAPIMixin: + """API calls for past motion alerts.""" + def get_motion_files(self, start: dt, end: dt = dt.now(), + streamtype: str = 'sub', channel = 0) -> PROCESSED_MOTION_LIST_TYPE: + """ + Get the timestamps and filenames of motion detection events for the time range provided. + + Args: + start: the starting time range to examine + end: the end time of the time range to examine + streamtype: 'main' or 'sub' - the stream to examine + :return: response json + """ + search_params = { + 'Search': { + 'channel': channel, + 'streamType': streamtype, + 'onlyStatus': 0, + 'StartTime': { + 'year': start.year, + 'mon': start.month, + 'day': start.day, + 'hour': start.hour, + 'min': start.minute, + 'sec': start.second + }, + 'EndTime': { + 'year': end.year, + 'mon': end.month, + 'day': end.day, + 'hour': end.hour, + 'min': end.minute, + 'sec': end.second + } + } + } + body = [{"cmd": "Search", "action": 1, "param": search_params}] + + resp = self._execute_command('Search', body)[0] + if 'value' not in resp: + return [] + values = resp['value'] + if 'SearchResult' not in values: + return [] + result = values['SearchResult'] + files = result.get('File', []) + if len(files) > 0: + # Begin processing files + processed_files = self._process_motion_files(files) + return processed_files + return [] + + @staticmethod + def _process_motion_files(motion_files: RAW_MOTION_LIST_TYPE) -> PROCESSED_MOTION_LIST_TYPE: + """Processes raw list of dicts containing motion timestamps + and the filename associated with them""" + # Process files + processed_motions = [] + replace_fields = {'mon': 'month', 'sec': 'second', 'min': 'minute'} + for file in motion_files: + time_range = {} + for x in ['Start', 'End']: + # Get raw dict + raw = file[f'{x}Time'] + # Replace certain keys + for k, v in replace_fields.items(): + if k in raw.keys(): + raw[v] = raw.pop(k) + time_range[x.lower()] = dt(**raw) + start, end = time_range.values() + processed_motions.append({ + 'start': start, + 'end': end, + 'filename': file['name'] + }) + return processed_motions diff --git a/reolinkapi/mixins/network.py b/reolinkapi/mixins/network.py new file mode 100644 index 0000000..ae44ba4 --- /dev/null +++ b/reolinkapi/mixins/network.py @@ -0,0 +1,175 @@ +from typing import Dict + + +class NetworkAPIMixin: + """API calls for network settings.""" + def set_network_settings(self, ip: str, gateway: str, mask: str, dns1: str, dns2: str, mac: str, + use_dhcp: bool = True, auto_dns: bool = True) -> Dict: + """ + Set network settings including IP, gateway, subnet mask, DNS, and connection type (DHCP or Static). + + :param ip: str + :param gateway: str + :param mask: str + :param dns1: str + :param dns2: str + :param mac: str + :param use_dhcp: bool + :param auto_dns: bool + :return: Dict + """ + body = [{"cmd": "SetLocalLink", "action": 0, "param": { + "LocalLink": { + "dns": { + "auto": 1 if auto_dns else 0, + "dns1": dns1, + "dns2": dns2 + }, + "mac": mac, + "static": { + "gateway": gateway, + "ip": ip, + "mask": mask + }, + "type": "DHCP" if use_dhcp else "Static" + } + }}] + + return self._execute_command('SetLocalLink', body) + print("Successfully Set Network Settings") + return True + + def set_net_port(self, http_port: float = 80, https_port: float = 443, media_port: float = 9000, + onvif_port: float = 8000, rtmp_port: float = 1935, rtsp_port: float = 554) -> bool: + """ + Set network ports + If nothing is specified, the default values will be used + :param rtsp_port: int + :param rtmp_port: int + :param onvif_port: int + :param media_port: int + :param https_port: int + :type http_port: int + :return: bool + """ + body = [{"cmd": "SetNetPort", "action": 0, "param": {"NetPort": { + "httpPort": http_port, + "httpsPort": https_port, + "mediaPort": media_port, + "onvifPort": onvif_port, + "rtmpPort": rtmp_port, + "rtspPort": rtsp_port + }}}] + self._execute_command('SetNetPort', body, multi=True) + print("Successfully Set Network Ports") + return True + + def set_wifi(self, ssid: str, password: str) -> Dict: + body = [{"cmd": "SetWifi", "action": 0, "param": { + "Wifi": { + "ssid": ssid, + "password": password + }}}] + return self._execute_command('SetWifi', body) + + def set_ntp(self, enable: bool = True, interval: int = 1440, port: int = 123, server: str = "pool.ntp.org") -> Dict: + """ + Set NTP settings. + + :param enable: bool + :param interval: int + :param port: int + :param server: str + :return: Dict + """ + body = [{"cmd": "SetNtp", "action": 0, "param": { + "Ntp": { + "enable": int(enable), + "interval": interval, + "port": port, + "server": server + }}}] + response = self._execute_command('SetNtp', body) + print("Successfully Set NTP Settings") + return response + + def get_net_ports(self) -> Dict: + """ + Get network ports + See examples/response/GetNetworkAdvanced.json for example response data. + :return: response json + """ + body = [{"cmd": "GetNetPort", "action": 1, "param": {}}, + {"cmd": "GetUpnp", "action": 0, "param": {}}, + {"cmd": "GetP2p", "action": 0, "param": {}}] + return self._execute_command('GetNetPort', body, multi=True) + + def get_wifi(self) -> Dict: + body = [{"cmd": "GetWifi", "action": 1, "param": {}}] + return self._execute_command('GetWifi', body) + + def scan_wifi(self) -> Dict: + body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] + return self._execute_command('ScanWifi', body) + + def get_network_general(self) -> Dict: + """ + Get the camera information + See examples/response/GetNetworkGeneral.json for example response data. + :return: response json + """ + body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}] + return self._execute_command('GetLocalLink', body) + + def get_network_ddns(self) -> Dict: + """ + Get the camera DDNS network information + See examples/response/GetNetworkDDNS.json for example response data. + :return: response json + """ + body = [{"cmd": "GetDdns", "action": 0, "param": {}}] + return self._execute_command('GetDdns', body) + + def get_network_ntp(self) -> Dict: + """ + Get the camera NTP network information + See examples/response/GetNetworkNTP.json for example response data. + :return: response json + """ + body = [{"cmd": "GetNtp", "action": 0, "param": {}}] + return self._execute_command('GetNtp', body) + + def get_network_email(self) -> Dict: + """ + Get the camera email network information + See examples/response/GetNetworkEmail.json for example response data. + :return: response json + """ + body = [{"cmd": "GetEmail", "action": 0, "param": {}}] + return self._execute_command('GetEmail', body) + + def get_network_ftp(self) -> Dict: + """ + Get the camera FTP network information + See examples/response/GetNetworkFtp.json for example response data. + :return: response json + """ + body = [{"cmd": "GetFtp", "action": 0, "param": {}}] + return self._execute_command('GetFtp', body) + + def get_network_push(self) -> Dict: + """ + Get the camera push network information + See examples/response/GetNetworkPush.json for example response data. + :return: response json + """ + body = [{"cmd": "GetPush", "action": 0, "param": {}}] + return self._execute_command('GetPush', body) + + def get_network_status(self) -> Dict: + """ + Get the camera status network information + See examples/response/GetNetworkGeneral.json for example response data. + :return: response json + """ + return self.get_network_general() diff --git a/reolinkapi/mixins/nvrdownload.py b/reolinkapi/mixins/nvrdownload.py new file mode 100644 index 0000000..c4e3051 --- /dev/null +++ b/reolinkapi/mixins/nvrdownload.py @@ -0,0 +1,54 @@ +from datetime import datetime as dt + +class NvrDownloadAPIMixin: + """API calls for NvrDownload.""" + def get_playback_files(self, start: dt, end: dt = dt.now(), channel: int = 0, + streamtype: str = 'sub'): + """ + Get the filenames of the videos for the time range provided. + + Args: + start: the starting time range to examine + end: the end time of the time range to examine + channel: which channel to download from + streamtype: 'main' or 'sub' - the stream to examine + :return: response json + """ + search_params = { + 'NvrDownload': { + 'channel': channel, + 'iLogicChannel': 0, + 'streamType': streamtype, + 'StartTime': { + 'year': start.year, + 'mon': start.month, + 'day': start.day, + 'hour': start.hour, + 'min': start.minute, + 'sec': start.second + }, + 'EndTime': { + 'year': end.year, + 'mon': end.month, + 'day': end.day, + 'hour': end.hour, + 'min': end.minute, + 'sec': end.second + } + } + } + body = [{"cmd": "NvrDownload", "action": 1, "param": search_params}] + try: + resp = self._execute_command('NvrDownload', body)[0] + except Exception as e: + print(f"Error: {e}") + return [] + if 'value' not in resp: + return [] + values = resp['value'] + if 'fileList' not in values: + return [] + files = values['fileList'] + if len(files) > 0: + return [file['fileName'] for file in files] + return [] diff --git a/reolinkapi/mixins/ptz.py b/reolinkapi/mixins/ptz.py new file mode 100644 index 0000000..fd11dfc --- /dev/null +++ b/reolinkapi/mixins/ptz.py @@ -0,0 +1,156 @@ +from typing import Dict + + +class PtzAPIMixin: + """ + API for PTZ functions. + """ + def get_ptz_check_state(self) -> Dict: + """ + Get PTZ Check State Information that indicates whether calibration is required (0) running (1) or done (2) + Value is contained in response[0]["value"]["PtzCheckState"]. + See examples/response/GetPtzCheckState.json for example response data. + :return: response json + """ + body = [{"cmd": "GetPtzCheckState", "action": 1, "param": { "channel": 0}}] + return self._execute_command('GetPtzCheckState', body) + + def get_ptz_presets(self) -> Dict: + """ + Get ptz presets + See examples/response/GetPtzPresets.json for example response data. + :return: response json + """ + + body = [{"cmd": "GetPtzPreset", "action": 1, "param": { "channel": 0}}] + return self._execute_command('GetPtzPreset', body) + + def perform_calibration(self) -> Dict: + """ + Do the calibration (like app -> ptz -> three dots -> calibration). Moves camera to all end positions. + If not calibrated, your viewpoint of presets might drift. So before setting new presets, or moving to preset, + check calibration status (get_ptz_check_state -> 2 = calibrated) and perform calibration if not yet calibrated. + As of 2024-01-23 (most recent firmware 3.1.0.1711_23010700 for E1 Zoom) does not do this on startup. + Method blocks while calibrating. + See examples/response/PtzCheck.json for example response data. + :return: response json + """ + data = [{"cmd": "PtzCheck", "action": 0, "param": {"channel": 0}}] + return self._execute_command('PtzCheck', data) + + def _send_operation(self, operation: str, speed: float, index: float = None) -> Dict: + # Refactored to reduce redundancy + param = {"channel": 0, "op": operation, "speed": speed} + if index is not None: + param['id'] = index + data = [{"cmd": "PtzCtrl", "action": 0, "param": param}] + return self._execute_command('PtzCtrl', data) + + def _send_noparm_operation(self, operation: str) -> Dict: + data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation}}] + return self._execute_command('PtzCtrl', data) + + def _send_set_preset(self, enable: float, preset: float = 1, name: str = 'pos1') -> Dict: + data = [{"cmd": "SetPtzPreset", "action": 0, "param": { "PtzPreset": { + "channel": 0, "enable": enable, "id": preset, "name": name}}}] + return self._execute_command('PtzCtrl', data) + + def go_to_preset(self, speed: float = 60, index: float = 1) -> Dict: + """ + Move the camera to a preset location + :return: response json + """ + return self._send_operation('ToPos', speed=speed, index=index) + + def add_preset(self, preset: float = 1, name: str = 'pos1') -> Dict: + """ + Adds the current camera position to the specified preset. + :return: response json + """ + return self._send_set_preset(enable=1, preset=preset, name=name) + + def remove_preset(self, preset: float = 1, name: str = 'pos1') -> Dict: + """ + Removes the specified preset + :return: response json + """ + return self._send_set_preset(enable=0, preset=preset, name=name) + + def move_right(self, speed: float = 25) -> Dict: + """ + Move the camera to the right + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Right', speed=speed) + + def move_right_up(self, speed: float = 25) -> Dict: + """ + Move the camera to the right and up + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('RightUp', speed=speed) + + def move_right_down(self, speed: float = 25) -> Dict: + """ + Move the camera to the right and down + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('RightDown', speed=speed) + + def move_left(self, speed: float = 25) -> Dict: + """ + Move the camera to the left + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Left', speed=speed) + + def move_left_up(self, speed: float = 25) -> Dict: + """ + Move the camera to the left and up + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('LeftUp', speed=speed) + + def move_left_down(self, speed: float = 25) -> Dict: + """ + Move the camera to the left and down + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('LeftDown', speed=speed) + + def move_up(self, speed: float = 25) -> Dict: + """ + Move the camera up. + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Up', speed=speed) + + def move_down(self, speed: float = 25) -> Dict: + """ + Move the camera down. + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Down', speed=speed) + + def stop_ptz(self) -> Dict: + """ + Stops the cameras current action. + :return: response json + """ + return self._send_noparm_operation('Stop') + + def auto_movement(self, speed: float = 25) -> Dict: + """ + Move the camera in a clockwise rotation. + The camera moves self.stop_ptz() is called. + :return: response json + """ + return self._send_operation('Auto', speed=speed) diff --git a/reolinkapi/mixins/record.py b/reolinkapi/mixins/record.py new file mode 100644 index 0000000..375b5ca --- /dev/null +++ b/reolinkapi/mixins/record.py @@ -0,0 +1,72 @@ +from typing import Dict + + +class RecordAPIMixin: + """API calls for the recording settings""" + + def get_recording_encoding(self) -> Dict: + """ + Get the current camera encoding settings for "Clear" and "Fluent" profiles. + See examples/response/GetEnc.json for example response data. + :return: response json + """ + body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetEnc', body) + + def get_recording_advanced(self) -> Dict: + """ + Get recording advanced setup data + See examples/response/GetRec.json for example response data. + :return: response json + """ + body = [{"cmd": "GetRec", "action": 1, "param": {"channel": 0}}] + return self._execute_command('GetRec', body) + + def set_recording_encoding(self, + audio: float = 0, + main_bit_rate: float = 8192, + main_frame_rate: float = 8, + main_profile: str = 'High', + main_size: str = "2560*1440", + sub_bit_rate: float = 160, + sub_frame_rate: float = 7, + sub_profile: str = 'High', + sub_size: str = '640*480') -> Dict: + """ + Sets the current camera encoding settings for "Clear" and "Fluent" profiles. + :param audio: int Audio on or off + :param main_bit_rate: int Clear Bit Rate + :param main_frame_rate: int Clear Frame Rate + :param main_profile: string Clear Profile + :param main_size: string Clear Size + :param sub_bit_rate: int Fluent Bit Rate + :param sub_frame_rate: int Fluent Frame Rate + :param sub_profile: string Fluent Profile + :param sub_size: string Fluent Size + :return: response + """ + body = [ + { + "cmd": "SetEnc", + "action": 0, + "param": { + "Enc": { + "audio": audio, + "channel": 0, + "mainStream": { + "bitRate": main_bit_rate, + "frameRate": main_frame_rate, + "profile": main_profile, + "size": main_size + }, + "subStream": { + "bitRate": sub_bit_rate, + "frameRate": sub_frame_rate, + "profile": sub_profile, + "size": sub_size + } + } + } + } + ] + return self._execute_command('SetEnc', body) diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py new file mode 100644 index 0000000..76dc239 --- /dev/null +++ b/reolinkapi/mixins/stream.py @@ -0,0 +1,67 @@ +import string +from random import choices +from typing import Any, Optional +from urllib import parse +from io import BytesIO + +import requests + +try: + from PIL.Image import Image, open as open_image + + from reolinkapi.utils.rtsp_client import RtspClient + + + class StreamAPIMixin: + """ API calls for opening a video stream or capturing an image from the camera.""" + + def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any: + """ + 'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player' + Blocking function creates a generator and returns the frames as it is spawned + :param callback: + :param proxies: Default is none, example: {"host": "localhost", "port": 8000} + """ + rtsp_client = RtspClient( + ip=self.ip, username=self.username, password=self.password, profile=self.profile, proxies=proxies, callback=callback) + return rtsp_client.open_stream() + + def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional[Image]: + """ + Gets a "snap" of the current camera video data and returns a Pillow Image or None + :param timeout: Request timeout to camera in seconds + :param proxies: http/https proxies to pass to the request object. + :return: Image or None + """ + data = { + 'cmd': 'Snap', + 'channel': 0, + 'rs': ''.join(choices(string.ascii_uppercase + string.digits, k=10)), + 'user': self.username, + 'password': self.password, + } + parms = parse.urlencode(data, safe="!").encode("utf-8") + + try: + response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout) + if response.status_code == 200: + return open_image(BytesIO(response.content)) + print("Could not retrieve data from camera successfully. Status:", response.status_code) + return None + + except Exception as e: + print("Could not get Image data\n", e) + raise +except ImportError as err: + print("ImportError", err) + + class StreamAPIMixin: + """ API calls for opening a video stream or capturing an image from the camera.""" + + def open_video_stream(self, callback: Any = None, proxies: Any = None) -> Any: + raise ImportError(f'open_video_stream requires streaming extra dependencies\nFor instance "pip install ' + f'reolinkapi[streaming]"') + + def get_snap(self, timeout: float = 3, proxies: Any = None) -> Optional['Image']: + raise ImportError( + f'get_snap requires streaming extra dependencies\nFor instance "pip install reolinkapi[streaming]"') diff --git a/reolinkapi/mixins/system.py b/reolinkapi/mixins/system.py new file mode 100644 index 0000000..dcb590a --- /dev/null +++ b/reolinkapi/mixins/system.py @@ -0,0 +1,44 @@ +from typing import Dict + + +class SystemAPIMixin: + """API for accessing general system information of the camera.""" + def get_general_system(self) -> Dict: + """:return: response json""" + body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}] + return self._execute_command('get_general_system', body, multi=True) + + def get_performance(self) -> Dict: + """ + Get a snapshot of the current performance of the camera. + See examples/response/GetPerformance.json for example response data. + :return: response json + """ + body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] + return self._execute_command('GetPerformance', body) + + def get_information(self) -> Dict: + """ + Get the camera information + See examples/response/GetDevInfo.json for example response data. + :return: response json + """ + body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] + return self._execute_command('GetDevInfo', body) + + def reboot_camera(self) -> Dict: + """ + Reboots the camera + :return: response json + """ + body = [{"cmd": "Reboot", "action": 0, "param": {}}] + return self._execute_command('Reboot', body) + + def get_dst(self) -> Dict: + """ + Get the camera DST information + See examples/response/GetDSTInfo.json for example response data. + :return: response json + """ + body = [{"cmd": "GetTime", "action": 0, "param": {}}] + return self._execute_command('GetTime', body) diff --git a/reolinkapi/mixins/user.py b/reolinkapi/mixins/user.py new file mode 100644 index 0000000..c382c2d --- /dev/null +++ b/reolinkapi/mixins/user.py @@ -0,0 +1,65 @@ +from typing import Dict + + +class UserAPIMixin: + """User-related API calls.""" + def get_online_user(self) -> Dict: + """ + Return a list of current logged-in users in json format + See examples/response/GetOnline.json for example response data. + :return: response json + """ + body = [{"cmd": "GetOnline", "action": 1, "param": {}}] + return self._execute_command('GetOnline', body) + + def get_users(self) -> Dict: + """ + Return a list of user accounts from the camera in json format. + See examples/response/GetUser.json for example response data. + :return: response json + """ + body = [{"cmd": "GetUser", "action": 1, "param": {}}] + return self._execute_command('GetUser', body) + + def add_user(self, username: str, password: str, level: str = "guest") -> bool: + """ + Add a new user account to the camera + :param username: The user's username + :param password: The user's password + :param level: The privilege level 'guest' or 'admin'. Default is 'guest' + :return: whether the user was added successfully + """ + body = [{"cmd": "AddUser", "action": 0, + "param": {"User": {"userName": username, "password": password, "level": level}}}] + r_data = self._execute_command('AddUser', body)[0] + if r_data["value"]["rspCode"] == 200: + return True + print("Could not add user. Camera responded with:", r_data["value"]) + return False + + def modify_user(self, username: str, password: str) -> bool: + """ + Modify the user's password by specifying their username + :param username: The user which would want to be modified + :param password: The new password + :return: whether the user was modified successfully + """ + body = [{"cmd": "ModifyUser", "action": 0, "param": {"User": {"userName": username, "password": password}}}] + r_data = self._execute_command('ModifyUser', body)[0] + if r_data["value"]["rspCode"] == 200: + return True + print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}") + return False + + def delete_user(self, username: str) -> bool: + """ + Delete a user by specifying their username + :param username: The user which would want to be deleted + :return: whether the user was deleted successfully + """ + body = [{"cmd": "DelUser", "action": 0, "param": {"User": {"userName": username}}}] + r_data = self._execute_command('DelUser', body)[0] + if r_data["value"]["rspCode"] == 200: + return True + print(f"Could not delete user: {username}\nCamera responded with: {r_data['value']}") + return False diff --git a/reolinkapi/mixins/zoom.py b/reolinkapi/mixins/zoom.py new file mode 100644 index 0000000..74549e0 --- /dev/null +++ b/reolinkapi/mixins/zoom.py @@ -0,0 +1,84 @@ +from typing import Dict + + +class ZoomAPIMixin: + """ + API for zooming and changing focus. + Note that the API does not allow zooming/focusing by absolute + values rather that changing focus/zoom for a given time. + """ + def _start_operation(self, operation: str, speed: float) -> Dict: + data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}] + return self._execute_command('PtzCtrl', data) + + def _stop_zooming_or_focusing(self) -> Dict: + """This command stops any ongoing zooming or focusing actions.""" + data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}] + return self._execute_command('PtzCtrl', data) + + def get_zoom_focus(self) -> Dict: + """This command returns the current zoom and focus values.""" + data = [{"cmd": "GetZoomFocus", "action": 0, "param": {"channel": 0}}] + return self._execute_command('GetZoomFocus', data) + + def start_zoom_pos(self, position: float) -> Dict: + """This command sets the zoom position.""" + data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "ZoomPos", "pos": position}}}] + return self._execute_command('StartZoomFocus', data) + + def start_focus_pos(self, position: float) -> Dict: + """This command sets the focus position.""" + data = [{"cmd": "StartZoomFocus", "action": 0, "param": {"ZoomFocus": {"channel": 0, "op": "FocusPos", "pos": position}}}] + return self._execute_command('StartZoomFocus', data) + + def get_auto_focus(self) -> Dict: + """This command returns the current auto focus status.""" + data = [{"cmd": "GetAutoFocus", "action": 0, "param": {"channel": 0}}] + return self._execute_command('GetAutoFocus', data) + + def set_auto_focus(self, disable: bool) -> Dict: + """This command sets the auto focus status.""" + data = [{"cmd": "SetAutoFocus", "action": 0, "param": {"AutoFocus": {"channel": 0, "disable": 1 if disable else 0}}}] + return self._execute_command('SetAutoFocus', data) + + def start_zooming_in(self, speed: float = 60) -> Dict: + """ + The camera zooms in until self.stop_zooming() is called. + :return: response json + """ + return self._start_operation('ZoomInc', speed=speed) + + def start_zooming_out(self, speed: float = 60) -> Dict: + """ + The camera zooms out until self.stop_zooming() is called. + :return: response json + """ + return self._start_operation('ZoomDec', speed=speed) + + def stop_zooming(self) -> Dict: + """ + Stop zooming. + :return: response json + """ + return self._stop_zooming_or_focusing() + + def start_focusing_in(self, speed: float = 32) -> Dict: + """ + The camera focuses in until self.stop_focusing() is called. + :return: response json + """ + return self._start_operation('FocusInc', speed=speed) + + def start_focusing_out(self, speed: float = 32) -> Dict: + """ + The camera focuses out until self.stop_focusing() is called. + :return: response json + """ + return self._start_operation('FocusDec', speed=speed) + + def stop_focusing(self) -> Dict: + """ + Stop focusing. + :return: response json + """ + return self._stop_zooming_or_focusing() diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/utils/rtsp_client.py b/reolinkapi/utils/rtsp_client.py new file mode 100644 index 0000000..e260a74 --- /dev/null +++ b/reolinkapi/utils/rtsp_client.py @@ -0,0 +1,105 @@ +import os +from threading import ThreadError +from typing import Any +import cv2 +from reolinkapi.utils.util import threaded + + +class RtspClient: + """ + This is a wrapper of the OpenCV VideoCapture method + Inspiration from: + - https://benhowell.github.io/guide/2015/03/09/opencv-and-web-cam-streaming + - https://stackoverflow.com/questions/19846332/python-threading-inside-a-class + - https://stackoverflow.com/questions/55828451/video-streaming-from-ip-camera-in-python-using-opencv-cv2-videocapture + """ + + def __init__(self, ip: str, username: str, password: str, port: float = 554, profile: str = "main", + use_udp: bool = True, callback: Any = None, **kwargs): + """ + RTSP client is used to retrieve frames from the camera in a stream + + :param ip: Camera IP + :param username: Camera Username + :param password: Camera User Password + :param port: RTSP port + :param profile: "main" or "sub" + :param use_upd: True to use UDP, False to use TCP + :param proxies: {"host": "localhost", "port": 8000} + """ + self.capture = None + self.thread_cancelled = False + self.callback = callback + + capture_options = 'rtsp_transport;' + self.ip = ip + self.username = username + self.password = password + self.port = port + self.proxy = kwargs.get("proxies") + self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}' + capture_options += 'udp' if use_udp else 'tcp' + + os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options + + # opens the stream capture, but does not retrieve any frames yet. + self._open_video_capture() + + def _open_video_capture(self): + # To CAP_FFMPEG or not To ? + self.capture = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) + + def _stream_blocking(self): + while True: + try: + if self.capture.isOpened(): + ret, frame = self.capture.read() + if ret: + yield frame + else: + print("stream closed") + self.capture.release() + return + except Exception as e: + print(e) + self.capture.release() + return + + @threaded + def _stream_non_blocking(self): + while not self.thread_cancelled: + try: + if self.capture.isOpened(): + ret, frame = self.capture.read() + if ret: + self.callback(frame) + else: + print("stream is closed") + self.stop_stream() + except ThreadError as e: + print(e) + self.stop_stream() + + def stop_stream(self): + self.capture.release() + self.thread_cancelled = True + + def open_stream(self): + """ + Opens OpenCV Video stream and returns the result according to the OpenCV documentation + https://docs.opencv.org/3.4/d8/dfe/classcv_1_1VideoCapture.html#a473055e77dd7faa4d26d686226b292c1 + """ + + # Reset the capture object + if self.capture is None or not self.capture.isOpened(): + self._open_video_capture() + + print("opening stream") + + if self.callback is None: + return self._stream_blocking() + else: + # reset the thread status if the object was not re-created + if not self.thread_cancelled: + self.thread_cancelled = False + return self._stream_non_blocking() diff --git a/reolinkapi/utils/util.py b/reolinkapi/utils/util.py new file mode 100644 index 0000000..83cf0ba --- /dev/null +++ b/reolinkapi/utils/util.py @@ -0,0 +1,11 @@ +from threading import Thread + + +def threaded(fn): + def wrapper(*args, **kwargs): + thread = Thread(target=fn, args=args, kwargs=kwargs) + thread.daemon = True + thread.start() + return thread + + return wrapper diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..945c9b4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/resthandle.py b/resthandle.py deleted file mode 100644 index e632686..0000000 --- a/resthandle.py +++ /dev/null @@ -1,45 +0,0 @@ -import json - -import requests - - -class Request: - - @staticmethod - def post(url: str, data, params=None) -> requests.Response or None: - """ - Post request - :param params: - :param url: - :param data: - :return: - """ - try: - headers = {'content-type': 'application/json'} - if params is not None: - r = requests.post(url, params=params, json=data, headers=headers) - else: - r = requests.post(url, json=data) - if r.status_code == 200: - return r - else: - raise ValueError("Status: ", r.status_code) - except Exception as e: - print("Post Error\n", e) - raise - - @staticmethod - def get(url, params, timeout=1) -> json or None: - """ - Get request - :param url: - :param params: - :param timeout: - :return: - """ - try: - data = requests.get(url=url, params=params, timeout=timeout) - return data - except Exception as e: - print("Get Error\n", e) - raise diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b36aaf8 --- /dev/null +++ b/setup.py @@ -0,0 +1,63 @@ +#!/usr/bin/python3 +import os +import re +import codecs +from setuptools import setup, find_packages + + +def read(*parts): + with codecs.open(os.path.join(here, *parts), 'r') as fp: + return fp.read() + + +def find_version(*file_paths): + version_file = read(*file_paths) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +# Package meta-data. +NAME = 'reolinkapi' +DESCRIPTION = 'Reolink Camera API client written in Python 3' +URL = 'https://github.com/ReolinkCameraAPI/reolinkapipy' +AUTHOR_EMAIL = 'alanoterblanche@gmail.com' +AUTHOR = 'Benehiko' +LICENSE = 'GPL-3.0' +INSTALL_REQUIRES = [ + 'setuptools', + 'PySocks==1.7.1', + 'PyYaml==6.0.2', + 'requests>=2.32.3', +] +EXTRAS_REQUIRE = { + 'streaming': [ + 'numpy==2.0.1', + 'opencv-python==4.10.0.84', + 'Pillow==10.4.0', + ], +} + + +here = os.path.abspath(os.path.dirname(__file__)) +# read the contents of your README file +with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + + +setup( + name=NAME, + python_requires='>=3.12.4', + version=find_version('reolinkapi', '__init__.py'), + description=DESCRIPTION, + long_description=long_description, + long_description_content_type='text/markdown', + author=AUTHOR, + author_email=AUTHOR_EMAIL, + url=URL, + license=LICENSE, + install_requires=INSTALL_REQUIRES, + packages=find_packages(exclude=['examples', 'tests']), + extras_require=EXTRAS_REQUIRE +) diff --git a/test.py b/test.py deleted file mode 100644 index 158ad3f..0000000 --- a/test.py +++ /dev/null @@ -1,5 +0,0 @@ -from Camera import Camera - -c = Camera("192.168.1.100", "admin", "jUa2kUzi") -c.get_wifi() -c.scan_wifi() diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..c5cdf98 --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,46 @@ +import os +from configparser import RawConfigParser +import unittest +from reolinkapi import Camera + + +def read_config(props_path: str) -> dict: + """Reads in a properties file into variables. + + NB! this config file is kept out of commits with .gitignore. The structure of this file is such: + # secrets.cfg + [camera] + ip={ip_address} + username={username} + password={password} + """ + config = RawConfigParser() + assert os.path.exists(props_path), f"Path does not exist: {props_path}" + config.read(props_path) + return config + + +class TestCamera(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls.config = read_config('../secrets.cfg') + + def setUp(self) -> None: + self.cam = Camera(self.config.get('camera', 'ip'), self.config.get('camera', 'username'), + self.config.get('camera', 'password')) + + def test_camera(self): + """Test that camera connects and gets a token""" + self.assertTrue(self.cam.ip == self.config.get('camera', 'ip')) + self.assertTrue(self.cam.token != '') + + def test_snapshot(self): + img = self.cam.get_snap() + # write Pillow Image to file + img.save('./tmp/snaps/camera.jpg') + self.assertTrue(os.path.exists('./tmp/snaps/camera.jpg')) + + +if __name__ == '__main__': + unittest.main()