From 91c92b0fcb721f94f9472a432f3e8d3e81774135 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 12:41:46 -0600 Subject: [PATCH 01/13] Apply intial changes to make repo perform better as a Python module --- Pipfile | 16 -- Pipfile.lock | 169 ------------------ README.md | 113 +----------- api/__init__.py | 4 - {api => reolink_api}/APIHandler.py | 30 ++-- Camera.py => reolink_api/Camera.py | 4 +- .../ConfigHandler.py | 1 - RtspClient.py => reolink_api/RtspClient.py | 7 +- reolink_api/__init__.py | 4 + {api => reolink_api}/alarm.py | 0 {api => reolink_api}/device.py | 9 +- {api => reolink_api}/display.py | 0 reolink_api/download.py | 15 ++ {api => reolink_api}/image.py | 42 ++--- reolink_api/motion.py | 41 +++++ {api => reolink_api}/network.py | 6 +- {api => reolink_api}/ptz.py | 0 {api => reolink_api}/recording.py | 2 +- resthandle.py => reolink_api/resthandle.py | 4 +- {api => reolink_api}/system.py | 0 {api => reolink_api}/user.py | 0 util.py => reolink_api/util.py | 0 {api => reolink_api}/zoom.py | 0 requirements.txt | 4 - setup.py | 88 ++++----- test.py | 5 - 26 files changed, 158 insertions(+), 406 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock delete mode 100644 api/__init__.py rename {api => reolink_api}/APIHandler.py (92%) rename Camera.py => reolink_api/Camera.py (84%) rename ConfigHandler.py => reolink_api/ConfigHandler.py (99%) rename RtspClient.py => reolink_api/RtspClient.py (94%) create mode 100644 reolink_api/__init__.py rename {api => reolink_api}/alarm.py (100%) rename {api => reolink_api}/device.py (83%) rename {api => reolink_api}/display.py (100%) create mode 100644 reolink_api/download.py rename {api => reolink_api}/image.py (67%) create mode 100644 reolink_api/motion.py rename {api => reolink_api}/network.py (94%) rename {api => reolink_api}/ptz.py (100%) rename {api => reolink_api}/recording.py (99%) rename resthandle.py => reolink_api/resthandle.py (94%) rename {api => reolink_api}/system.py (100%) rename {api => reolink_api}/user.py (100%) rename util.py => reolink_api/util.py (100%) rename {api => reolink_api}/zoom.py (100%) delete mode 100644 requirements.txt delete mode 100644 test.py diff --git a/Pipfile b/Pipfile deleted file mode 100644 index defbd59..0000000 --- a/Pipfile +++ /dev/null @@ -1,16 +0,0 @@ -[[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/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 15b8698..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,169 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "idna": { - "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" - ], - "version": "==2.9" - }, - "numpy": { - "hashes": [ - "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6", - "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e", - "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc", - "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc", - "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a", - "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa", - "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3", - "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121", - "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971", - "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26", - "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd", - "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480", - "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec", - "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77", - "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57", - "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07", - "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572", - "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73", - "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca", - "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474", - "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5" - ], - "index": "pypi", - "version": "==1.18.1" - }, - "opencv-python": { - "hashes": [ - "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3", - "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50", - "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb", - "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b", - "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d", - "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a", - "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d", - "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951", - "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c", - "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098", - "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20", - "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f", - "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145", - "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee", - "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b", - "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e", - "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946", - "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db", - "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3", - "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268", - "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179", - "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2", - "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2", - "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9", - "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944", - "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00", - "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417" - ], - "index": "pypi", - "version": "==4.2.0.32" - }, - "pillow": { - "hashes": [ - "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", - "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", - "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", - "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", - "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", - "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", - "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", - "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", - "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", - "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", - "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", - "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", - "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", - "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", - "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", - "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", - "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", - "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", - "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", - "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", - "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", - "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" - ], - "index": "pypi", - "version": "==7.0.0" - }, - "pysocks": { - "hashes": [ - "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", - "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", - "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" - ], - "index": "pypi", - "version": "==1.7.1" - }, - "pyyaml": { - "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" - ], - "index": "pypi", - "version": "==5.3" - }, - "requests": { - "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" - ], - "index": "pypi", - "version": "==2.23.0" - }, - "urllib3": { - "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" - ], - "version": "==1.25.8" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index 37f295f..e8ef0da 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,15 @@ -

Reolink Python Api Client

- -

- GitHub - GitHub tag (latest SemVer) - PyPI -

- ---- - +# (Forked) Reolink Python Api Client A Reolink Camera client written in Python. -Other Supported Languages: - - Go: [reolink-go-api](https://github.com/ReolinkCameraAPI/reolink-go-api) - -### Join us on Discord - - https://discord.gg/8z3fdAmZJP +_NB! for the original API client of this fork, go [here](https://github.com/ReolinkCameraAPI/reolink-python-api)_ ### Purpose -This repository's purpose is to deliver a complete API for the Reolink Camera's, ( TESTED on RLC-411WS ) +This repository's purpose is to deliver a complete API for the Reolink Camera's, (tested on RLC-522) +### Installation -### But Reolink gives an API in their documentation +```bash +python3 -m pip install git+https://github.com/barretobrock/reolink-python-api.git +``` -Not really. They only deliver a really basic API to retrieve Image data and Video data. - -### How? - -You can get the Restful API calls by looking through the HTTP Requests made the camera web console. I use Google Chrome developer mode (ctr + shift + i) -> Network. - -### Get started - -Implement a "Camera" object by passing it an IP address, Username and Password. By instantiating the object, it will try retrieve a login token from the Reolink Camera. This token is necessary to interact with the Camera using other commands. - -See the `examples` directory. - -### Using the library as a Python Module - -Install the package via Pip - - pip install reolink-api==0.0.5 - -## Contributors - ---- - -### Styling and Standards - -This project intends to stick with [PEP8](https://www.python.org/dev/peps/pep-0008/) - -### API Requests Implementation Plan: - -Stream: -- [X] Blocking RTSP stream -- [X] Non-Blocking RTSP stream - -GET: -- [X] Login -- [X] Logout -- [X] Display -> OSD -- [X] Recording -> Encode (Clear and Fluent Stream) -- [X] Recording -> Advance (Scheduling) -- [X] Network -> General -- [X] Network -> Advanced -- [X] Network -> DDNS -- [X] Network -> NTP -- [X] Network -> E-mail -- [X] Network -> FTP -- [X] Network -> Push -- [X] Network -> WIFI -- [X] Alarm -> Motion -- [X] System -> General -- [X] System -> DST -- [X] System -> Information -- [ ] System -> Maintenance -- [X] System -> Performance -- [ ] System -> Reboot -- [X] User -> Online User -- [X] User -> Add User -- [X] User -> Manage User -- [X] Device -> HDD/SD Card -- [ ] Zoom -- [ ] Focus -- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) -- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) -- [X] Image Data -> "Snap" Frame from Video Stream - -SET: -- [X] Display -> OSD -- [X] Recording -> Encode (Clear and Fluent Stream) -- [ ] Recording -> Advance (Scheduling) -- [X] Network -> General -- [X] Network -> Advanced -- [ ] Network -> DDNS -- [ ] Network -> NTP -- [ ] Network -> E-mail -- [ ] Network -> FTP -- [ ] Network -> Push -- [X] Network -> WIFI -- [ ] Alarm -> Motion -- [ ] System -> General -- [ ] System -> DST -- [X] System -> Reboot -- [X] User -> Online User -- [X] User -> Add User -- [X] User -> Manage User -- [X] Device -> HDD/SD Card (Format) -- [x] PTZ -- [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) diff --git a/api/__init__.py b/api/__init__.py deleted file mode 100644 index 491da40..0000000 --- a/api/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .APIHandler import APIHandler - -__version__ = "0.0.5" -VERSION = __version__ diff --git a/api/APIHandler.py b/reolink_api/APIHandler.py similarity index 92% rename from api/APIHandler.py rename to reolink_api/APIHandler.py index a4a6f07..3b4754d 100644 --- a/api/APIHandler.py +++ b/reolink_api/APIHandler.py @@ -1,26 +1,30 @@ -from .recording import RecordingAPIMixin -from .zoom import ZoomAPIMixin +from reolink_api.resthandle import Request +from .alarm import AlarmAPIMixin from .device import DeviceAPIMixin from .display import DisplayAPIMixin +from .download import DownloadAPIMixin +from .image import ImageAPIMixin +from .motion import MotionAPIMixin from .network import NetworkAPIMixin +from .ptz import PtzAPIMixin +from .recording import RecordingAPIMixin from .system import SystemAPIMixin from .user import UserAPIMixin -from .ptz import PtzAPIMixin -from .alarm import AlarmAPIMixin -from .image import ImageAPIMixin -from resthandle import Request +from .zoom import ZoomAPIMixin -class APIHandler(SystemAPIMixin, - NetworkAPIMixin, - UserAPIMixin, +class APIHandler(AlarmAPIMixin, DeviceAPIMixin, DisplayAPIMixin, - RecordingAPIMixin, - ZoomAPIMixin, + DownloadAPIMixin, + ImageAPIMixin, + MotionAPIMixin, + NetworkAPIMixin, PtzAPIMixin, - AlarmAPIMixin, - ImageAPIMixin): + RecordingAPIMixin, + SystemAPIMixin, + UserAPIMixin, + ZoomAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. diff --git a/Camera.py b/reolink_api/Camera.py similarity index 84% rename from Camera.py rename to reolink_api/Camera.py index a60490a..0a166f1 100644 --- a/Camera.py +++ b/reolink_api/Camera.py @@ -1,9 +1,9 @@ -from api import APIHandler +from reolink_api import APIHandler class Camera(APIHandler): - def __init__(self, ip, username="admin", password="", https=False): + def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = False): """ Initialise the Camera object by passing the ip address. The default details {"username":"admin", "password":""} will be used if nothing passed diff --git a/ConfigHandler.py b/reolink_api/ConfigHandler.py similarity index 99% rename from ConfigHandler.py rename to reolink_api/ConfigHandler.py index 67e8d62..37f255e 100644 --- a/ConfigHandler.py +++ b/reolink_api/ConfigHandler.py @@ -1,5 +1,4 @@ import io - import yaml diff --git a/RtspClient.py b/reolink_api/RtspClient.py similarity index 94% rename from RtspClient.py rename to reolink_api/RtspClient.py index 6cf37c1..655f95f 100644 --- a/RtspClient.py +++ b/reolink_api/RtspClient.py @@ -1,9 +1,7 @@ import os from threading import ThreadError - import cv2 - -from util import threaded +from reolink_api.util import threaded class RtspClient: @@ -91,9 +89,6 @@ class RtspClient: """ 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 - - :param callback: The function to callback the cv::mat frame to if required to be non-blocking. If this is left - as None, then the function returns a generator which is blocking. """ # Reset the capture object diff --git a/reolink_api/__init__.py b/reolink_api/__init__.py new file mode 100644 index 0000000..1eca8c1 --- /dev/null +++ b/reolink_api/__init__.py @@ -0,0 +1,4 @@ +from .APIHandler import APIHandler +from .Camera import Camera + +__version__ = "0.1.0" diff --git a/api/alarm.py b/reolink_api/alarm.py similarity index 100% rename from api/alarm.py rename to reolink_api/alarm.py diff --git a/api/device.py b/reolink_api/device.py similarity index 83% rename from api/device.py rename to reolink_api/device.py index deee890..68f8179 100644 --- a/api/device.py +++ b/reolink_api/device.py @@ -1,5 +1,10 @@ +from typing import List + + class DeviceAPIMixin: """API calls for getting device information.""" + DEFAULT_HDD_ID = [0] + def get_hdd_info(self) -> object: """ Gets all HDD and SD card information from Camera @@ -9,12 +14,14 @@ class DeviceAPIMixin: body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] return self._execute_command('GetHddInfo', body) - def format_hdd(self, hdd_id: [int] = [0]) -> bool: + def format_hdd(self, hdd_id: List[int] = 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: diff --git a/api/display.py b/reolink_api/display.py similarity index 100% rename from api/display.py rename to reolink_api/display.py diff --git a/reolink_api/download.py b/reolink_api/download.py new file mode 100644 index 0000000..45494d9 --- /dev/null +++ b/reolink_api/download.py @@ -0,0 +1,15 @@ +class DownloadAPIMixin: + """API calls for downloading video files.""" + def get_file(self, filename: str) -> object: + """ + Download the selected video file + :return: response json + """ + body = [ + { + "cmd": "Download", + "source": filename, + "output": filename + } + ] + return self._execute_command('Download', body) diff --git a/api/image.py b/reolink_api/image.py similarity index 67% rename from api/image.py rename to reolink_api/image.py index 6cdb823..0fbb952 100644 --- a/api/image.py +++ b/reolink_api/image.py @@ -3,22 +3,22 @@ class ImageAPIMixin: """API calls for image settings.""" def set_adv_image_settings(self, - anti_flicker='Outdoor', - exposure='Auto', - gain_min=1, - gain_max=62, - shutter_min=1, - shutter_max=125, - blue_gain=128, - red_gain=128, - white_balance='Auto', - day_night='Auto', - back_light='DynamicRangeControl', - blc=128, - drc=128, - rotation=0, - mirroring=0, - nr3d=1) -> object: + anti_flicker: str = 'Outdoor', + exposure: str = 'Auto', + gain_min: int = 1, + gain_max: int = 62, + shutter_min: int = 1, + shutter_max: int = 125, + blue_gain: int = 128, + red_gain: int = 128, + white_balance: str = 'Auto', + day_night: str = 'Auto', + back_light: str = 'DynamicRangeControl', + blc: int = 128, + drc: int = 128, + rotation: int = 0, + mirroring: int = 0, + nr3d: int = 1) -> object: """ Sets the advanced camera settings. @@ -66,11 +66,11 @@ class ImageAPIMixin: return self._execute_command('SetIsp', body) def set_image_settings(self, - brightness=128, - contrast=62, - hue=1, - saturation=125, - sharpness=128) -> object: + brightness: int = 128, + contrast: int = 62, + hue: int = 1, + saturation: int = 125, + sharpness: int = 128) -> object: """ Sets the camera image settings. diff --git a/reolink_api/motion.py b/reolink_api/motion.py new file mode 100644 index 0000000..1ce8071 --- /dev/null +++ b/reolink_api/motion.py @@ -0,0 +1,41 @@ +from datetime import datetime as dt + + +class MotionAPIMixin: + """API calls for past motion alerts.""" + def get_motion_files(self, start: dt, end: dt = dt.now(), + streamtype: str = 'main') -> object: + """ + 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': 0, + '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}] + return self._execute_command('Search', body) diff --git a/api/network.py b/reolink_api/network.py similarity index 94% rename from api/network.py rename to reolink_api/network.py index 39af7b8..54bbe2d 100644 --- a/api/network.py +++ b/reolink_api/network.py @@ -1,7 +1,7 @@ class NetworkAPIMixin: """API calls for network settings.""" - def set_net_port(self, http_port=80, https_port=443, media_port=9000, onvif_port=8000, rtmp_port=1935, - rtsp_port=554) -> bool: + def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: int = 9000, + onvif_port: int = 8000, rtmp_port: int = 1935, rtsp_port: int = 554) -> bool: """ Set network ports If nothing is specified, the default values will be used @@ -25,7 +25,7 @@ class NetworkAPIMixin: print("Successfully Set Network Ports") return True - def set_wifi(self, ssid, password) -> object: + def set_wifi(self, ssid: str, password: str) -> object: body = [{"cmd": "SetWifi", "action": 0, "param": { "Wifi": { "ssid": ssid, diff --git a/api/ptz.py b/reolink_api/ptz.py similarity index 100% rename from api/ptz.py rename to reolink_api/ptz.py diff --git a/api/recording.py b/reolink_api/recording.py similarity index 99% rename from api/recording.py rename to reolink_api/recording.py index 8827249..195cce7 100644 --- a/api/recording.py +++ b/reolink_api/recording.py @@ -4,7 +4,7 @@ import string from urllib import parse from io import BytesIO from PIL import Image -from RtspClient import RtspClient +from reolink_api.RtspClient import RtspClient class RecordingAPIMixin: diff --git a/resthandle.py b/reolink_api/resthandle.py similarity index 94% rename from resthandle.py rename to reolink_api/resthandle.py index d2f98b7..ac3fad9 100644 --- a/resthandle.py +++ b/reolink_api/resthandle.py @@ -1,5 +1,4 @@ import json - import requests @@ -17,7 +16,8 @@ class Request: """ try: headers = {'content-type': 'application/json'} - r = requests.post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies) + r = requests.post(url, verify=False, params=params, json=data, headers=headers, + proxies=Request.proxies) # if params is not None: # r = requests.post(url, params=params, json=data, headers=headers, proxies=proxies) # else: diff --git a/api/system.py b/reolink_api/system.py similarity index 100% rename from api/system.py rename to reolink_api/system.py diff --git a/api/user.py b/reolink_api/user.py similarity index 100% rename from api/user.py rename to reolink_api/user.py diff --git a/util.py b/reolink_api/util.py similarity index 100% rename from util.py rename to reolink_api/util.py diff --git a/api/zoom.py b/reolink_api/zoom.py similarity index 100% rename from api/zoom.py rename to reolink_api/zoom.py diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 30b468d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests -opencv-python -numpy -socks \ No newline at end of file diff --git a/setup.py b/setup.py index 98eba70..da818d1 100644 --- a/setup.py +++ b/setup.py @@ -1,32 +1,9 @@ #!/usr/bin/python3 - import os import re import codecs from setuptools import setup -# Package meta-data. -NAME = 'reolink-api' -DESCRIPTION = 'Reolink Camera API written in Python 3.6' -URL = 'https://github.com/Benehiko/ReolinkCameraAPI' -AUTHOR_EMAIL = '' -AUTHOR = 'Benehiko' -LICENSE = 'GPL-3.0' -INSTALL_REQUIRES = [ - 'pillow', - 'pyyaml', - 'requests>=2.18.4', - 'numpy', - 'opencv-python', - 'pysocks' -] - - -here = os.path.abspath(os.path.dirname(__file__)) -# read the contents of your README file -with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - def read(*parts): with codecs.open(os.path.join(here, *parts), 'r') as fp: @@ -41,32 +18,39 @@ def find_version(*file_paths): raise RuntimeError("Unable to find version string.") -setup(name=NAME, - python_requires='>=3.6.0', - version=find_version('api', '__init__.py'), - description=DESCRIPTION, - long_description=long_description, - long_description_content_type='text/markdown', - author=AUTHOR, - author_email=AUTHOR_EMAIL, - url=URL, - license=LICENSE, - install_requires=INSTALL_REQUIRES, - py_modules=[ - 'Camera', - 'ConfigHandler', - 'RtspClient', - 'resthandle', - 'api.APIHandler', - 'api.device', - 'api.display', - 'api.network', - 'api.ptz', - 'api.recording', - 'api.system', - 'api.user', - 'api.zoom', - 'api.alarm', - 'api.image' - ] - ) +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() + + +# Package meta-data. +NAME = 'reolink_api' +DESCRIPTION = 'Reolink Camera API written in Python 3.6' +URL = 'https://github.com/Benehiko/ReolinkCameraAPI' +AUTHOR_EMAIL = '' +AUTHOR = 'Benehiko' +LICENSE = 'GPL-3.0' +INSTALL_REQUIRES = [ + 'numpy==1.19.4', + 'opencv-python==4.4.0.46', + 'Pillow==8.0.1', + 'PySocks==1.7.1', + 'PyYaml==5.3.1', + 'requests>=2.18.4', +] + + +setup( + name=NAME, + python_requires='>=3.6.0', + version=find_version('reolink_api', '__init__.py'), + description=DESCRIPTION, + long_description=long_description, + long_description_content_type='text/markdown', + author=AUTHOR, + author_email=AUTHOR_EMAIL, + url=URL, + license=LICENSE, + install_requires=INSTALL_REQUIRES +) diff --git a/test.py b/test.py deleted file mode 100644 index 796f5a2..0000000 --- a/test.py +++ /dev/null @@ -1,5 +0,0 @@ -from Camera import Camera - -c = Camera("192.168.1.112", "admin", "jUa2kUzi") -# print("Getting information", c.get_information()) -c.open_video_stream() From 00835e3543e90adf35eec824626a352f007c6df2 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 13:38:20 -0600 Subject: [PATCH 02/13] Add changes from original examples file, apply find_packages to setup, add tests --- .gitignore | 1 + examples/streaming_video.py | 3 +-- setup.py | 5 +++-- tests/test_camera.py | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 tests/test_camera.py diff --git a/.gitignore b/.gitignore index 368d535..2836d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +secrets.cfg # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/examples/streaming_video.py b/examples/streaming_video.py index 90dc2a9..d67b1d0 100644 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -1,6 +1,5 @@ import cv2 - -from Camera import Camera +from reolink_api.Camera import Camera def non_blocking(): diff --git a/setup.py b/setup.py index da818d1..e89fd5b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import os import re import codecs -from setuptools import setup +from setuptools import setup, find_packages def read(*parts): @@ -52,5 +52,6 @@ setup( author_email=AUTHOR_EMAIL, url=URL, license=LICENSE, - install_requires=INSTALL_REQUIRES + install_requires=INSTALL_REQUIRES, + packages=find_packages(exclude=['examples', 'tests']) ) diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..2fa5cf2 --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,40 @@ +import os +from configparser import RawConfigParser +import unittest +from reolink_api 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 != '') + + +if __name__ == '__main__': + unittest.main() From 86117de420d766300878e39bfdbb8675d4dc94cb Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 14:56:10 -0600 Subject: [PATCH 03/13] Improvements to motion detection and download methods, add to examples --- examples/download_motions.py | 43 ++++++++++++++++++++++++++++++++++++ reolink_api/APIHandler.py | 19 +++++++++++++++- reolink_api/download.py | 9 +++++--- reolink_api/motion.py | 42 +++++++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 examples/download_motions.py diff --git a/examples/download_motions.py b/examples/download_motions.py new file mode 100644 index 0000000..31f66f8 --- /dev/null +++ b/examples/download_motions.py @@ -0,0 +1,43 @@ +import os +from configparser import RawConfigParser +from datetime import datetime as dt, timedelta +from reolink_api 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('../secrets.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(hours=1)) +end = dt.now() +# Collect motion events between these timestamps for substream +processed_motions = cam.get_motion_files(start=start, end=end, streamtype='sub') + +dl_dir = os.path.join(os.path.expanduser('~'), 'Downloads') +for i, motion in enumerate(processed_motions): + fname = motion['filename'] + # Download the mp4 + resp = cam.get_file(fname, output_path=os.path.join(dl_dir, f'motion_event_{i}.mp4')) diff --git a/reolink_api/APIHandler.py b/reolink_api/APIHandler.py index 3b4754d..093fd2c 100644 --- a/reolink_api/APIHandler.py +++ b/reolink_api/APIHandler.py @@ -1,3 +1,4 @@ +import requests from reolink_api.resthandle import Request from .alarm import AlarmAPIMixin from .device import DeviceAPIMixin @@ -109,7 +110,23 @@ class APIHandler(AlarmAPIMixin, try: if self.token is None: raise ValueError("Login first") - response = Request.post(self.url, data=data, params=params) + if command == 'Download': + # 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) 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}") diff --git a/reolink_api/download.py b/reolink_api/download.py index 45494d9..ebd1603 100644 --- a/reolink_api/download.py +++ b/reolink_api/download.py @@ -1,6 +1,6 @@ class DownloadAPIMixin: """API calls for downloading video files.""" - def get_file(self, filename: str) -> object: + def get_file(self, filename: str, output_path: str) -> bool: """ Download the selected video file :return: response json @@ -9,7 +9,10 @@ class DownloadAPIMixin: { "cmd": "Download", "source": filename, - "output": filename + "output": filename, + "filepath": output_path } ] - return self._execute_command('Download', body) + resp = self._execute_command('Download', body) + + return resp diff --git a/reolink_api/motion.py b/reolink_api/motion.py index 1ce8071..246a74a 100644 --- a/reolink_api/motion.py +++ b/reolink_api/motion.py @@ -1,10 +1,16 @@ +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, int, 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 = 'main') -> object: + streamtype: str = 'sub') -> PROCESSED_MOTION_LIST_TYPE: """ Get the timestamps and filenames of motion detection events for the time range provided. @@ -38,4 +44,36 @@ class MotionAPIMixin: } } body = [{"cmd": "Search", "action": 1, "param": search_params}] - return self._execute_command('Search', body) + + resp = self._execute_command('Search', body)[0] + files = resp['value']['SearchResult']['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 From 7afa58a5ce0056cc9fa79128b8bae1322aa7b82c Mon Sep 17 00:00:00 2001 From: Bobrock Date: Sun, 13 Dec 2020 14:56:40 -0600 Subject: [PATCH 04/13] Version bump --- reolink_api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reolink_api/__init__.py b/reolink_api/__init__.py index 1eca8c1..6d78770 100644 --- a/reolink_api/__init__.py +++ b/reolink_api/__init__.py @@ -1,4 +1,4 @@ from .APIHandler import APIHandler from .Camera import Camera -__version__ = "0.1.0" +__version__ = "0.1.1" From 856ede1b390b8832ad4addae4629b8a8b2d10ca2 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Wed, 16 Dec 2020 07:45:17 -0600 Subject: [PATCH 05/13] Resolve issue when querying motion files results in empty results --- reolink_api/motion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reolink_api/motion.py b/reolink_api/motion.py index 246a74a..63a8ecf 100644 --- a/reolink_api/motion.py +++ b/reolink_api/motion.py @@ -46,7 +46,8 @@ class MotionAPIMixin: body = [{"cmd": "Search", "action": 1, "param": search_params}] resp = self._execute_command('Search', body)[0] - files = resp['value']['SearchResult']['File'] + result = resp['value']['SearchResult'] + files = result.get('File', []) if len(files) > 0: # Begin processing files processed_files = self._process_motion_files(files) From 84ed8481e278175d77dc085d41197d143d5e84f9 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 08:48:56 -0600 Subject: [PATCH 06/13] Add back pipfiles, README --- Pipfile | 16 ++++ Pipfile.lock | 169 +++++++++++++++++++++++++++++++++++ README.md | 149 ++++++++++++++++++++++++++++-- reolink_api/APIHandler.py | 133 --------------------------- reolink_api/Camera.py | 24 ----- reolink_api/ConfigHandler.py | 16 ---- reolink_api/RtspClient.py | 106 ---------------------- 7 files changed, 325 insertions(+), 288 deletions(-) create mode 100644 Pipfile create mode 100644 Pipfile.lock delete mode 100644 reolink_api/APIHandler.py delete mode 100644 reolink_api/Camera.py delete mode 100644 reolink_api/ConfigHandler.py delete mode 100644 reolink_api/RtspClient.py 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/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..15b8698 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,169 @@ +{ + "_meta": { + "hash": { + "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + ], + "version": "==2019.11.28" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "numpy": { + "hashes": [ + "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6", + "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e", + "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc", + "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc", + "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a", + "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa", + "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3", + "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121", + "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971", + "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26", + "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd", + "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480", + "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec", + "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77", + "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57", + "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07", + "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572", + "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73", + "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca", + "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474", + "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5" + ], + "index": "pypi", + "version": "==1.18.1" + }, + "opencv-python": { + "hashes": [ + "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3", + "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50", + "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb", + "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b", + "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d", + "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a", + "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d", + "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951", + "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c", + "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098", + "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20", + "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f", + "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145", + "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee", + "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b", + "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e", + "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946", + "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db", + "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3", + "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268", + "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179", + "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2", + "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2", + "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9", + "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944", + "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00", + "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417" + ], + "index": "pypi", + "version": "==4.2.0.32" + }, + "pillow": { + "hashes": [ + "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", + "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", + "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", + "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", + "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", + "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", + "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", + "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", + "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", + "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", + "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", + "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", + "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", + "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", + "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", + "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", + "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", + "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", + "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", + "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", + "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", + "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" + ], + "index": "pypi", + "version": "==7.0.0" + }, + "pysocks": { + "hashes": [ + "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", + "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", + "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" + ], + "index": "pypi", + "version": "==1.7.1" + }, + "pyyaml": { + "hashes": [ + "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", + "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", + "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", + "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", + "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", + "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", + "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", + "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", + "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", + "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", + "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + ], + "index": "pypi", + "version": "==5.3" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "index": "pypi", + "version": "==2.23.0" + }, + "urllib3": { + "hashes": [ + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + ], + "version": "==1.25.8" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index e8ef0da..19332b7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,146 @@ -# (Forked) Reolink Python Api Client -A Reolink Camera client written in Python. +

Reolink Python Api Client

-_NB! for the original API client of this fork, go [here](https://github.com/ReolinkCameraAPI/reolink-python-api)_ +

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

-### Purpose +--- -This repository's purpose is to deliver a complete API for the Reolink Camera's, (tested on RLC-522) +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. -### Installation +Check out our documentation for more information on how to use the software at [https://reolink.oleaintueri.com](https://reolink.oleaintueri.com) -```bash -python3 -m pip install git+https://github.com/barretobrock/reolink-python-api.git -``` +Other Supported Languages: + - Go: [reolinkapigo](https://github.com/ReolinkCameraAPI/reolinkapigo) + +### 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 Pip + + pip install reolink-api==0.0.5 + +Install from GitHub + + pip install git+https://github.com/ReolinkCameraAPI/reolink-python-api.git + +## 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 and make your changes. + +#### Step 3 + +Make a pull request. + +### API Requests Implementation Plan: + +Stream: +- [X] Blocking RTSP stream +- [X] Non-Blocking RTSP stream + +GET: +- [X] Login +- [X] Logout +- [X] Display -> OSD +- [X] Recording -> Encode (Clear and Fluent Stream) +- [X] Recording -> Advance (Scheduling) +- [X] Network -> General +- [X] Network -> Advanced +- [X] Network -> DDNS +- [X] Network -> NTP +- [X] Network -> E-mail +- [X] Network -> FTP +- [X] Network -> Push +- [X] Network -> WIFI +- [X] Alarm -> Motion +- [X] System -> General +- [X] System -> DST +- [X] System -> Information +- [ ] System -> Maintenance +- [X] System -> Performance +- [ ] System -> Reboot +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card +- [ ] Zoom +- [ ] Focus +- [ ] Image (Brightness, Contrast, Saturation, Hue, Sharp, Mirror, Rotate) +- [ ] Advanced Image (Anti-flicker, Exposure, White Balance, DayNight, Backlight, LED light, 3D-NR) +- [X] Image Data -> "Snap" Frame from Video Stream + +SET: +- [X] Display -> OSD +- [X] Recording -> Encode (Clear and Fluent Stream) +- [ ] Recording -> Advance (Scheduling) +- [X] Network -> General +- [X] Network -> Advanced +- [ ] Network -> DDNS +- [ ] Network -> NTP +- [ ] Network -> E-mail +- [ ] Network -> FTP +- [ ] Network -> Push +- [X] Network -> WIFI +- [ ] Alarm -> Motion +- [ ] System -> General +- [ ] System -> DST +- [X] System -> Reboot +- [X] User -> Online User +- [X] User -> Add User +- [X] User -> Manage User +- [X] Device -> HDD/SD Card (Format) +- [x] PTZ +- [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-520 diff --git a/reolink_api/APIHandler.py b/reolink_api/APIHandler.py deleted file mode 100644 index 093fd2c..0000000 --- a/reolink_api/APIHandler.py +++ /dev/null @@ -1,133 +0,0 @@ -import requests -from reolink_api.resthandle import Request -from .alarm import AlarmAPIMixin -from .device import DeviceAPIMixin -from .display import DisplayAPIMixin -from .download import DownloadAPIMixin -from .image import ImageAPIMixin -from .motion import MotionAPIMixin -from .network import NetworkAPIMixin -from .ptz import PtzAPIMixin -from .recording import RecordingAPIMixin -from .system import SystemAPIMixin -from .user import UserAPIMixin -from .zoom import ZoomAPIMixin - - -class APIHandler(AlarmAPIMixin, - DeviceAPIMixin, - DisplayAPIMixin, - DownloadAPIMixin, - ImageAPIMixin, - MotionAPIMixin, - NetworkAPIMixin, - PtzAPIMixin, - RecordingAPIMixin, - SystemAPIMixin, - UserAPIMixin, - ZoomAPIMixin): - """ - 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=False, **kwargs): - """ - Initialise the Camera API Handler (maps api calls into python) - :param ip: - :param username: - :param password: - :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 - - 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: - print("Failed to login\nStatus Code:", response.status_code) - 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, data, multi=False): - """ - 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': - # 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) 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/reolink_api/Camera.py b/reolink_api/Camera.py deleted file mode 100644 index 0a166f1..0000000 --- a/reolink_api/Camera.py +++ /dev/null @@ -1,24 +0,0 @@ -from reolink_api import APIHandler - - -class Camera(APIHandler): - - def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = False): - """ - Initialise the Camera object by passing the ip address. - The default details {"username":"admin", "password":""} will be used if nothing passed - :param ip: - :param username: - :param password: - """ - # For when you need to connect to a camera behind a proxy, pass - # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"} - APIHandler.__init__(self, ip, username, password, https=https) - - # Normal call without proxy: - # APIHandler.__init__(self, ip, username, password) - - self.ip = ip - self.username = username - self.password = password - super().login() diff --git a/reolink_api/ConfigHandler.py b/reolink_api/ConfigHandler.py deleted file mode 100644 index 37f255e..0000000 --- a/reolink_api/ConfigHandler.py +++ /dev/null @@ -1,16 +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/reolink_api/RtspClient.py b/reolink_api/RtspClient.py deleted file mode 100644 index 655f95f..0000000 --- a/reolink_api/RtspClient.py +++ /dev/null @@ -1,106 +0,0 @@ -import os -from threading import ThreadError -import cv2 -from reolink_api.util import threaded - - -class RtspClient: - """ - 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, username, password, port=554, profile="main", use_udp=True, callback=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 = "rtsp://" + self.username + ":" + self.password + "@" + \ - self.ip + ":" + str(self.port) + "//h264Preview_01_" + profile - if use_udp: - capture_options = capture_options + 'udp' - else: - capture_options = capture_options + 'tcp' - - os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options - - # 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() From b10c29c6be80c4e00afd4645a860c6768d8059e3 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 08:50:32 -0600 Subject: [PATCH 07/13] Move description read-in beneath metadata --- setup.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index e89fd5b..5a7c521 100644 --- a/setup.py +++ b/setup.py @@ -18,12 +18,6 @@ def find_version(*file_paths): raise RuntimeError("Unable to find version string.") -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() - - # Package meta-data. NAME = 'reolink_api' DESCRIPTION = 'Reolink Camera API written in Python 3.6' @@ -41,6 +35,12 @@ INSTALL_REQUIRES = [ ] +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.6.0', From 17bc207e3bf8a4fc269a0c3abc00449adb464864 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 08:51:16 -0600 Subject: [PATCH 08/13] Add description to new example. Swap import on original example --- examples/download_motions.py | 1 + examples/streaming_video.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/download_motions.py b/examples/download_motions.py index 31f66f8..66d92da 100644 --- a/examples/download_motions.py +++ b/examples/download_motions.py @@ -1,3 +1,4 @@ +"""Downloads all motion events from camera from the past hour.""" import os from configparser import RawConfigParser from datetime import datetime as dt, timedelta diff --git a/examples/streaming_video.py b/examples/streaming_video.py index d67b1d0..8f0a01b 100644 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -1,5 +1,5 @@ import cv2 -from reolink_api.Camera import Camera +from reolink_api import Camera def non_blocking(): From 63537f9daf89c25594162bd13da80b98d174aa0c Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 09:05:56 -0600 Subject: [PATCH 09/13] Enforce lowercase standard on all submodule files, improve type hinting throughout, complete first pass for logic issues --- reolink_api/__init__.py | 6 +- reolink_api/alarm.py | 5 +- reolink_api/api_handler.py | 138 ++++++++++++++++++++++++++++++++++ reolink_api/camera.py | 24 ++++++ reolink_api/config_handler.py | 17 +++++ reolink_api/display.py | 35 +++++---- reolink_api/image.py | 34 +++++---- reolink_api/network.py | 25 +++--- reolink_api/ptz.py | 49 ++++++------ reolink_api/recording.py | 91 ++++++++++++---------- reolink_api/resthandle.py | 11 +-- reolink_api/rtsp_client.py | 110 +++++++++++++++++++++++++++ reolink_api/system.py | 15 ++-- reolink_api/user.py | 7 +- reolink_api/util.py | 2 +- reolink_api/zoom.py | 19 +++-- 16 files changed, 456 insertions(+), 132 deletions(-) create mode 100644 reolink_api/api_handler.py create mode 100644 reolink_api/camera.py create mode 100644 reolink_api/config_handler.py create mode 100644 reolink_api/rtsp_client.py diff --git a/reolink_api/__init__.py b/reolink_api/__init__.py index 6d78770..e623a67 100644 --- a/reolink_api/__init__.py +++ b/reolink_api/__init__.py @@ -1,4 +1,4 @@ -from .APIHandler import APIHandler -from .Camera import Camera +from .api_handler import APIHandler +from .camera import Camera -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/reolink_api/alarm.py b/reolink_api/alarm.py index 2f48efb..53bc6ee 100644 --- a/reolink_api/alarm.py +++ b/reolink_api/alarm.py @@ -1,7 +1,10 @@ +from typing import Dict + + class AlarmAPIMixin: """API calls for getting device alarm information.""" - def get_alarm_motion(self) -> object: + def get_alarm_motion(self) -> Dict: """ Gets the device alarm motion See examples/response/GetAlarmMotion.json for example response data. diff --git a/reolink_api/api_handler.py b/reolink_api/api_handler.py new file mode 100644 index 0000000..aa5c61f --- /dev/null +++ b/reolink_api/api_handler.py @@ -0,0 +1,138 @@ +import requests +from typing import Dict, List, Optional, Union +from reolink_api.alarm import AlarmAPIMixin +from reolink_api.device import DeviceAPIMixin +from reolink_api.display import DisplayAPIMixin +from reolink_api.download import DownloadAPIMixin +from reolink_api.image import ImageAPIMixin +from reolink_api.motion import MotionAPIMixin +from reolink_api.network import NetworkAPIMixin +from reolink_api.ptz import PtzAPIMixin +from reolink_api.recording import RecordingAPIMixin +from reolink_api.resthandle import Request +from reolink_api.system import SystemAPIMixin +from reolink_api.user import UserAPIMixin +from reolink_api.zoom import ZoomAPIMixin + + +class APIHandler(AlarmAPIMixin, + DeviceAPIMixin, + DisplayAPIMixin, + DownloadAPIMixin, + ImageAPIMixin, + MotionAPIMixin, + NetworkAPIMixin, + PtzAPIMixin, + RecordingAPIMixin, + SystemAPIMixin, + UserAPIMixin, + ZoomAPIMixin): + """ + 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 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 + + 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': + # 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) 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/reolink_api/camera.py b/reolink_api/camera.py new file mode 100644 index 0000000..47db97e --- /dev/null +++ b/reolink_api/camera.py @@ -0,0 +1,24 @@ +from .api_handler import APIHandler + + +class Camera(APIHandler): + + def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = False): + """ + Initialise the Camera object by passing the ip address. + The default details {"username":"admin", "password":""} will be used if nothing passed + :param ip: + :param username: + :param password: + """ + # For when you need to connect to a camera behind a proxy, pass + # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"} + APIHandler.__init__(self, ip, username, password, https=https) + + # Normal call without proxy: + # APIHandler.__init__(self, ip, username, password) + + self.ip = ip + self.username = username + self.password = password + super().login() diff --git a/reolink_api/config_handler.py b/reolink_api/config_handler.py new file mode 100644 index 0000000..a1c08ec --- /dev/null +++ b/reolink_api/config_handler.py @@ -0,0 +1,17 @@ +import io +import yaml +from typing import Optional, Dict + + +class ConfigHandler: + camera_settings = {} + + @staticmethod + def load() -> Optional[Dict]: + 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/reolink_api/display.py b/reolink_api/display.py index bf2b4ae..68fbb38 100644 --- a/reolink_api/display.py +++ b/reolink_api/display.py @@ -1,7 +1,10 @@ +from typing import Dict + + class DisplayAPIMixin: """API calls related to the current image (osd, on screen display).""" - def get_osd(self) -> object: + def get_osd(self) -> Dict: """ Get OSD information. See examples/response/GetOsd.json for example response data. @@ -10,7 +13,7 @@ class DisplayAPIMixin: body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetOsd', body) - def get_mask(self) -> object: + def get_mask(self) -> Dict: """ Get the camera mask information. See examples/response/GetMask.json for example response data. @@ -19,8 +22,8 @@ class DisplayAPIMixin: body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetMask', body) - def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, osd_channel_name: str = "", - osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0, + def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, + osd_channel_name: str = "", osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0, osd_time_pos: str = "Lower Right") -> bool: """ Set OSD @@ -28,18 +31,24 @@ class DisplayAPIMixin: :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_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"] + :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} - } - }}] + body = [{"cmd": "SetOsd", "action": 1, + "param": { + "Osd": { + "bgcolor": bg_color, + "channel": channel, + "osdChannel": { + "enable": osd_channel_enabled, "name": osd_channel_name, + "pos": osd_channel_pos + }, + "osdTime": {"enable": osd_time_enabled, "pos": osd_time_pos} + }}}] r_data = self._execute_command('SetOsd', body)[0] if r_data["value"]["rspCode"] == 200: return True diff --git a/reolink_api/image.py b/reolink_api/image.py index 0fbb952..8e30568 100644 --- a/reolink_api/image.py +++ b/reolink_api/image.py @@ -1,3 +1,5 @@ +from typing import Dict + class ImageAPIMixin: """API calls for image settings.""" @@ -5,20 +7,20 @@ class ImageAPIMixin: def set_adv_image_settings(self, anti_flicker: str = 'Outdoor', exposure: str = 'Auto', - gain_min: int = 1, - gain_max: int = 62, - shutter_min: int = 1, - shutter_max: int = 125, - blue_gain: int = 128, - red_gain: int = 128, + 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: int = 128, - drc: int = 128, - rotation: int = 0, - mirroring: int = 0, - nr3d: int = 1) -> object: + blc: float = 128, + drc: float = 128, + rotation: float = 0, + mirroring: float = 0, + nr3d: float = 1) -> Dict: """ Sets the advanced camera settings. @@ -66,11 +68,11 @@ class ImageAPIMixin: return self._execute_command('SetIsp', body) def set_image_settings(self, - brightness: int = 128, - contrast: int = 62, - hue: int = 1, - saturation: int = 125, - sharpness: int = 128) -> object: + brightness: float = 128, + contrast: float = 62, + hue: float = 1, + saturation: float = 125, + sharpness: float = 128) -> Dict: """ Sets the camera image settings. diff --git a/reolink_api/network.py b/reolink_api/network.py index 54bbe2d..8ec0cc3 100644 --- a/reolink_api/network.py +++ b/reolink_api/network.py @@ -1,3 +1,6 @@ +from typing import Dict + + class NetworkAPIMixin: """API calls for network settings.""" def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: int = 9000, @@ -25,7 +28,7 @@ class NetworkAPIMixin: print("Successfully Set Network Ports") return True - def set_wifi(self, ssid: str, password: str) -> object: + def set_wifi(self, ssid: str, password: str) -> Dict: body = [{"cmd": "SetWifi", "action": 0, "param": { "Wifi": { "ssid": ssid, @@ -33,7 +36,7 @@ class NetworkAPIMixin: }}}] return self._execute_command('SetWifi', body) - def get_net_ports(self) -> object: + def get_net_ports(self) -> Dict: """ Get network ports See examples/response/GetNetworkAdvanced.json for example response data. @@ -44,15 +47,15 @@ class NetworkAPIMixin: {"cmd": "GetP2p", "action": 0, "param": {}}] return self._execute_command('GetNetPort', body, multi=True) - def get_wifi(self): + def get_wifi(self) -> Dict: body = [{"cmd": "GetWifi", "action": 1, "param": {}}] return self._execute_command('GetWifi', body) - def scan_wifi(self): + def scan_wifi(self) -> Dict: body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] return self._execute_command('ScanWifi', body) - def get_network_general(self) -> object: + def get_network_general(self) -> Dict: """ Get the camera information See examples/response/GetNetworkGeneral.json for example response data. @@ -61,7 +64,7 @@ class NetworkAPIMixin: body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}] return self._execute_command('GetLocalLink', body) - def get_network_ddns(self) -> object: + def get_network_ddns(self) -> Dict: """ Get the camera DDNS network information See examples/response/GetNetworkDDNS.json for example response data. @@ -70,7 +73,7 @@ class NetworkAPIMixin: body = [{"cmd": "GetDdns", "action": 0, "param": {}}] return self._execute_command('GetDdns', body) - def get_network_ntp(self) -> object: + def get_network_ntp(self) -> Dict: """ Get the camera NTP network information See examples/response/GetNetworkNTP.json for example response data. @@ -79,7 +82,7 @@ class NetworkAPIMixin: body = [{"cmd": "GetNtp", "action": 0, "param": {}}] return self._execute_command('GetNtp', body) - def get_network_email(self) -> object: + def get_network_email(self) -> Dict: """ Get the camera email network information See examples/response/GetNetworkEmail.json for example response data. @@ -88,7 +91,7 @@ class NetworkAPIMixin: body = [{"cmd": "GetEmail", "action": 0, "param": {}}] return self._execute_command('GetEmail', body) - def get_network_ftp(self) -> object: + def get_network_ftp(self) -> Dict: """ Get the camera FTP network information See examples/response/GetNetworkFtp.json for example response data. @@ -97,7 +100,7 @@ class NetworkAPIMixin: body = [{"cmd": "GetFtp", "action": 0, "param": {}}] return self._execute_command('GetFtp', body) - def get_network_push(self) -> object: + def get_network_push(self) -> Dict: """ Get the camera push network information See examples/response/GetNetworkPush.json for example response data. @@ -106,7 +109,7 @@ class NetworkAPIMixin: body = [{"cmd": "GetPush", "action": 0, "param": {}}] return self._execute_command('GetPush', body) - def get_network_status(self) -> object: + def get_network_status(self) -> Dict: """ Get the camera status network information See examples/response/GetNetworkGeneral.json for example response data. diff --git a/reolink_api/ptz.py b/reolink_api/ptz.py index 463624e..80841a0 100644 --- a/reolink_api/ptz.py +++ b/reolink_api/ptz.py @@ -1,46 +1,49 @@ +from typing import Dict + + class PtzAPIMixin: """ API for PTZ functions. """ - def _send_operation(self, operation, speed, index=None): - if index is None: - data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}] - else: - data = [{"cmd": "PtzCtrl", "action": 0, "param": { - "channel": 0, "op": operation, "speed": speed, "id": index}}] + 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): + 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, operation, enable, preset=1, name='pos1'): + def _send_set_preset(self, enable: float, preset: float = 1, name: str = 'pos1') -> Dict: data = [{"cmd": "SetPtzPreset", "action": 0, "param": { "channel": 0, "enable": enable, "id": preset, "name": name}}] return self._execute_command('PtzCtrl', data) - def go_to_preset(self, speed=60, index=1): + 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=1, name='pos1'): + 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('PtzPreset', enable=1, preset=preset, name=name) + return self._send_set_preset(enable=1, preset=preset, name=name) - def remove_preset(self, preset=1, name='pos1'): + def remove_preset(self, preset: float = 1, name: str = 'pos1') -> Dict: """ Removes the specified preset :return: response json """ - return self._send_set_preset('PtzPreset', enable=0, preset=preset, name=name) + return self._send_set_preset(enable=0, preset=preset, name=name) - def move_right(self, speed=25): + def move_right(self, speed: float = 25) -> Dict: """ Move the camera to the right The camera moves self.stop_ptz() is called. @@ -48,7 +51,7 @@ class PtzAPIMixin: """ return self._send_operation('Right', speed=speed) - def move_right_up(self, speed=25): + 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. @@ -56,7 +59,7 @@ class PtzAPIMixin: """ return self._send_operation('RightUp', speed=speed) - def move_right_down(self, speed=25): + 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. @@ -64,7 +67,7 @@ class PtzAPIMixin: """ return self._send_operation('RightDown', speed=speed) - def move_left(self, speed=25): + def move_left(self, speed: float = 25) -> Dict: """ Move the camera to the left The camera moves self.stop_ptz() is called. @@ -72,7 +75,7 @@ class PtzAPIMixin: """ return self._send_operation('Left', speed=speed) - def move_left_up(self, speed=25): + 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. @@ -80,7 +83,7 @@ class PtzAPIMixin: """ return self._send_operation('LeftUp', speed=speed) - def move_left_down(self, speed=25): + 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. @@ -88,7 +91,7 @@ class PtzAPIMixin: """ return self._send_operation('LeftDown', speed=speed) - def move_up(self, speed=25): + def move_up(self, speed: float = 25) -> Dict: """ Move the camera up. The camera moves self.stop_ptz() is called. @@ -96,7 +99,7 @@ class PtzAPIMixin: """ return self._send_operation('Up', speed=speed) - def move_down(self, speed=25): + def move_down(self, speed: float = 25) -> Dict: """ Move the camera down. The camera moves self.stop_ptz() is called. @@ -104,14 +107,14 @@ class PtzAPIMixin: """ return self._send_operation('Down', speed=speed) - def stop_ptz(self): + def stop_ptz(self) -> Dict: """ Stops the cameras current action. :return: response json """ return self._send_noparm_operation('Stop') - def auto_movement(self, speed=25): + def auto_movement(self, speed: float = 25) -> Dict: """ Move the camera in a clockwise rotation. The camera moves self.stop_ptz() is called. diff --git a/reolink_api/recording.py b/reolink_api/recording.py index 195cce7..2170e42 100644 --- a/reolink_api/recording.py +++ b/reolink_api/recording.py @@ -3,14 +3,15 @@ import random import string from urllib import parse from io import BytesIO -from PIL import Image -from reolink_api.RtspClient import RtspClient +from typing import Dict, Any, Optional +from PIL.Image import Image, open as open_image +from reolink_api.rtsp_client import RtspClient class RecordingAPIMixin: """API calls for recording/streaming image or video.""" - def get_recording_encoding(self) -> object: + 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. @@ -19,7 +20,7 @@ class RecordingAPIMixin: body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetEnc', body) - def get_recording_advanced(self) -> object: + def get_recording_advanced(self) -> Dict: """ Get recording advanced setup data See examples/response/GetRec.json for example response data. @@ -29,15 +30,15 @@ class RecordingAPIMixin: return self._execute_command('GetRec', body) def set_recording_encoding(self, - audio=0, - main_bit_rate=8192, - main_frame_rate=8, - main_profile='High', - main_size="2560*1440", - sub_bit_rate=160, - sub_frame_rate=7, - sub_profile='High', - sub_size='640*480') -> object: + 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 @@ -51,59 +52,67 @@ class RecordingAPIMixin: :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}} - }}] + body = [ + { + "cmd": "SetEnc", + "action": 0, + "param": { + "Enc": { + "audio": audio, + "channel": 0, + "mainStream": { + "bitRate": main_bit_rate, + "frameRate": main_frame_rate, + "profile": main_profile, + "size": main_size + }, + "subStream": { + "bitRate": sub_bit_rate, + "frameRate": sub_frame_rate, + "profile": sub_profile, + "size": sub_size + } + } + } + } + ] return self._execute_command('SetEnc', body) ########### # RTSP Stream ########### - def open_video_stream(self, callback=None, profile: str = "main", proxies=None): + 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 profile: profile is "main" or "sub" + :param callback: :param proxies: Default is none, example: {"host": "localhost", "port": 8000} """ rtsp_client = RtspClient( ip=self.ip, username=self.username, password=self.password, proxies=proxies, callback=callback) return rtsp_client.open_stream() - def get_snap(self, timeout: int = 3, proxies=None) -> Image or None: + 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 = {} - data['cmd'] = 'Snap' - data['channel'] = 0 - data['rs'] = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) - data['user'] = self.username - data['password'] = self.password + data = { + 'cmd': 'Snap', + 'channel': 0, + 'rs': ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)), + 'user': self.username, + 'password': self.password, + } parms = parse.urlencode(data).encode("utf-8") try: response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout) if response.status_code == 200: - return Image.open(BytesIO(response.content)) - print("Could not retrieve data from camera successfully. Status:", response.stats_code) + return open_image(BytesIO(response.content)) + print("Could not retrieve data from camera successfully. Status:", response.status_code) return None except Exception as e: diff --git a/reolink_api/resthandle.py b/reolink_api/resthandle.py index ac3fad9..aa4704d 100644 --- a/reolink_api/resthandle.py +++ b/reolink_api/resthandle.py @@ -1,12 +1,14 @@ import json import requests +from typing import List, Dict, Union, Optional class Request: proxies = None @staticmethod - def post(url: str, data, params=None) -> requests.Response or None: + def post(url: str, data: List[Dict], params: Dict[str, Union[str, float]] = None) -> \ + Optional[requests.Response]: """ Post request :param params: @@ -18,10 +20,6 @@ class Request: headers = {'content-type': 'application/json'} r = requests.post(url, verify=False, params=params, json=data, headers=headers, proxies=Request.proxies) - # if params is not None: - # r = requests.post(url, params=params, json=data, headers=headers, proxies=proxies) - # else: - # r = requests.post(url, json=data) if r.status_code == 200: return r else: @@ -31,7 +29,7 @@ class Request: raise @staticmethod - def get(url, params, timeout=1) -> json or None: + def get(url: str, params: Dict[str, Union[str, float]], timeout: float = 1) -> Optional[requests.Response]: """ Get request :param url: @@ -41,7 +39,6 @@ class Request: """ try: data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) - return data except Exception as e: print("Get Error\n", e) diff --git a/reolink_api/rtsp_client.py b/reolink_api/rtsp_client.py new file mode 100644 index 0000000..75e16bb --- /dev/null +++ b/reolink_api/rtsp_client.py @@ -0,0 +1,110 @@ +import os +from threading import ThreadError +from typing import Any +import cv2 +from reolink_api.util import threaded + + +class RtspClient: + """ + 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}' + if use_udp: + capture_options = capture_options + 'udp' + else: + capture_options = capture_options + '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 + + :param callback: The function to callback the cv::mat frame to if required to be non-blocking. If this is left + as None, then the function returns a generator which is blocking. + """ + + # 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/reolink_api/system.py b/reolink_api/system.py index 0eadc6a..dcb590a 100644 --- a/reolink_api/system.py +++ b/reolink_api/system.py @@ -1,11 +1,14 @@ +from typing import Dict + + class SystemAPIMixin: """API for accessing general system information of the camera.""" - def get_general_system(self) -> object: + def get_general_system(self) -> Dict: """:return: response json""" body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}] return self._execute_command('get_general_system', body, multi=True) - def get_performance(self) -> object: + def get_performance(self) -> Dict: """ Get a snapshot of the current performance of the camera. See examples/response/GetPerformance.json for example response data. @@ -14,7 +17,7 @@ class SystemAPIMixin: body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] return self._execute_command('GetPerformance', body) - def get_information(self) -> object: + def get_information(self) -> Dict: """ Get the camera information See examples/response/GetDevInfo.json for example response data. @@ -23,7 +26,7 @@ class SystemAPIMixin: body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] return self._execute_command('GetDevInfo', body) - def reboot_camera(self) -> object: + def reboot_camera(self) -> Dict: """ Reboots the camera :return: response json @@ -31,11 +34,11 @@ class SystemAPIMixin: body = [{"cmd": "Reboot", "action": 0, "param": {}}] return self._execute_command('Reboot', body) - def get_dst(self) -> object: + def get_dst(self) -> Dict: """ Get the camera DST information See examples/response/GetDSTInfo.json for example response data. :return: response json """ body = [{"cmd": "GetTime", "action": 0, "param": {}}] - return self._execute_command('GetTime', body) \ No newline at end of file + return self._execute_command('GetTime', body) diff --git a/reolink_api/user.py b/reolink_api/user.py index 9d430f6..68a3915 100644 --- a/reolink_api/user.py +++ b/reolink_api/user.py @@ -1,6 +1,9 @@ +from typing import Dict + + class UserAPIMixin: """User-related API calls.""" - def get_online_user(self) -> object: + def get_online_user(self) -> Dict: """ Return a list of current logged-in users in json format See examples/response/GetOnline.json for example response data. @@ -9,7 +12,7 @@ class UserAPIMixin: body = [{"cmd": "GetOnline", "action": 1, "param": {}}] return self._execute_command('GetOnline', body) - def get_users(self) -> object: + def get_users(self) -> Dict: """ Return a list of user accounts from the camera in json format. See examples/response/GetUser.json for example response data. diff --git a/reolink_api/util.py b/reolink_api/util.py index c824002..83cf0ba 100644 --- a/reolink_api/util.py +++ b/reolink_api/util.py @@ -8,4 +8,4 @@ def threaded(fn): thread.start() return thread - return wrapper \ No newline at end of file + return wrapper diff --git a/reolink_api/zoom.py b/reolink_api/zoom.py index 2bf0021..0f5778d 100644 --- a/reolink_api/zoom.py +++ b/reolink_api/zoom.py @@ -1,54 +1,57 @@ +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, speed): + 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): + 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 start_zooming_in(self, speed=60): + 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=60): + 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): + def stop_zooming(self) -> Dict: """ Stop zooming. :return: response json """ return self._stop_zooming_or_focusing() - def start_focusing_in(self, speed=32): + 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=32): + 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): + def stop_focusing(self) -> Dict: """ Stop focusing. :return: response json From 7283bd3cab95c473521d62b63e0bb0a07c8eebc3 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 09:12:15 -0600 Subject: [PATCH 10/13] Reduce redundant code, remove unused arguments --- reolink_api/rtsp_client.py | 8 +------- reolink_api/user.py | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/reolink_api/rtsp_client.py b/reolink_api/rtsp_client.py index 75e16bb..6213ad4 100644 --- a/reolink_api/rtsp_client.py +++ b/reolink_api/rtsp_client.py @@ -37,10 +37,7 @@ class RtspClient: self.port = port self.proxy = kwargs.get("proxies") self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}' - if use_udp: - capture_options = capture_options + 'udp' - else: - capture_options = capture_options + 'tcp' + capture_options += 'udp' if use_udp else 'tcp' os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options @@ -90,9 +87,6 @@ class RtspClient: """ 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 - - :param callback: The function to callback the cv::mat frame to if required to be non-blocking. If this is left - as None, then the function returns a generator which is blocking. """ # Reset the capture object diff --git a/reolink_api/user.py b/reolink_api/user.py index 68a3915..c382c2d 100644 --- a/reolink_api/user.py +++ b/reolink_api/user.py @@ -48,7 +48,7 @@ class UserAPIMixin: r_data = self._execute_command('ModifyUser', body)[0] if r_data["value"]["rspCode"] == 200: return True - print("Could not modify user:", username, "\nCamera responded with:", r_data["value"]) + print(f"Could not modify user: {username}\nCamera responded with: {r_data['value']}") return False def delete_user(self, username: str) -> bool: @@ -61,5 +61,5 @@ class UserAPIMixin: r_data = self._execute_command('DelUser', body)[0] if r_data["value"]["rspCode"] == 200: return True - print("Could not delete user:", username, "\nCamera responded with:", r_data["value"]) + print(f"Could not delete user: {username}\nCamera responded with: {r_data['value']}") return False From 0a4898411b612747a2e4b8a34480f3fb2e630ede Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 15:58:01 -0600 Subject: [PATCH 11/13] Complete second logic pass, remove underscore from package name --- {reolink_api => reolinkapi}/__init__.py | 0 {reolink_api => reolinkapi}/alarm.py | 0 {reolink_api => reolinkapi}/api_handler.py | 26 +++++++++---------- {reolink_api => reolinkapi}/camera.py | 0 {reolink_api => reolinkapi}/config_handler.py | 0 {reolink_api => reolinkapi}/device.py | 6 ++--- {reolink_api => reolinkapi}/display.py | 2 +- {reolink_api => reolinkapi}/download.py | 0 {reolink_api => reolinkapi}/image.py | 0 {reolink_api => reolinkapi}/motion.py | 2 +- {reolink_api => reolinkapi}/network.py | 4 +-- {reolink_api => reolinkapi}/ptz.py | 0 {reolink_api => reolinkapi}/recording.py | 2 +- {reolink_api => reolinkapi}/resthandle.py | 1 - {reolink_api => reolinkapi}/rtsp_client.py | 2 +- {reolink_api => reolinkapi}/system.py | 0 {reolink_api => reolinkapi}/user.py | 0 {reolink_api => reolinkapi}/util.py | 0 {reolink_api => reolinkapi}/zoom.py | 0 19 files changed, 22 insertions(+), 23 deletions(-) rename {reolink_api => reolinkapi}/__init__.py (100%) rename {reolink_api => reolinkapi}/alarm.py (100%) rename {reolink_api => reolinkapi}/api_handler.py (89%) rename {reolink_api => reolinkapi}/camera.py (100%) rename {reolink_api => reolinkapi}/config_handler.py (100%) rename {reolink_api => reolinkapi}/device.py (88%) rename {reolink_api => reolinkapi}/display.py (96%) rename {reolink_api => reolinkapi}/download.py (100%) rename {reolink_api => reolinkapi}/image.py (100%) rename {reolink_api => reolinkapi}/motion.py (97%) rename {reolink_api => reolinkapi}/network.py (95%) rename {reolink_api => reolinkapi}/ptz.py (100%) rename {reolink_api => reolinkapi}/recording.py (99%) rename {reolink_api => reolinkapi}/resthandle.py (99%) rename {reolink_api => reolinkapi}/rtsp_client.py (98%) rename {reolink_api => reolinkapi}/system.py (100%) rename {reolink_api => reolinkapi}/user.py (100%) rename {reolink_api => reolinkapi}/util.py (100%) rename {reolink_api => reolinkapi}/zoom.py (100%) diff --git a/reolink_api/__init__.py b/reolinkapi/__init__.py similarity index 100% rename from reolink_api/__init__.py rename to reolinkapi/__init__.py diff --git a/reolink_api/alarm.py b/reolinkapi/alarm.py similarity index 100% rename from reolink_api/alarm.py rename to reolinkapi/alarm.py diff --git a/reolink_api/api_handler.py b/reolinkapi/api_handler.py similarity index 89% rename from reolink_api/api_handler.py rename to reolinkapi/api_handler.py index aa5c61f..3ceae88 100644 --- a/reolink_api/api_handler.py +++ b/reolinkapi/api_handler.py @@ -1,18 +1,18 @@ import requests from typing import Dict, List, Optional, Union -from reolink_api.alarm import AlarmAPIMixin -from reolink_api.device import DeviceAPIMixin -from reolink_api.display import DisplayAPIMixin -from reolink_api.download import DownloadAPIMixin -from reolink_api.image import ImageAPIMixin -from reolink_api.motion import MotionAPIMixin -from reolink_api.network import NetworkAPIMixin -from reolink_api.ptz import PtzAPIMixin -from reolink_api.recording import RecordingAPIMixin -from reolink_api.resthandle import Request -from reolink_api.system import SystemAPIMixin -from reolink_api.user import UserAPIMixin -from reolink_api.zoom import ZoomAPIMixin +from reolinkapi.alarm import AlarmAPIMixin +from reolinkapi.device import DeviceAPIMixin +from reolinkapi.display import DisplayAPIMixin +from reolinkapi.download import DownloadAPIMixin +from reolinkapi.image import ImageAPIMixin +from reolinkapi.motion import MotionAPIMixin +from reolinkapi.network import NetworkAPIMixin +from reolinkapi.ptz import PtzAPIMixin +from reolinkapi.recording import RecordingAPIMixin +from reolinkapi.resthandle import Request +from reolinkapi.system import SystemAPIMixin +from reolinkapi.user import UserAPIMixin +from reolinkapi.zoom import ZoomAPIMixin class APIHandler(AlarmAPIMixin, diff --git a/reolink_api/camera.py b/reolinkapi/camera.py similarity index 100% rename from reolink_api/camera.py rename to reolinkapi/camera.py diff --git a/reolink_api/config_handler.py b/reolinkapi/config_handler.py similarity index 100% rename from reolink_api/config_handler.py rename to reolinkapi/config_handler.py diff --git a/reolink_api/device.py b/reolinkapi/device.py similarity index 88% rename from reolink_api/device.py rename to reolinkapi/device.py index 68f8179..684be45 100644 --- a/reolink_api/device.py +++ b/reolinkapi/device.py @@ -1,11 +1,11 @@ -from typing import List +from typing import List, Dict class DeviceAPIMixin: """API calls for getting device information.""" DEFAULT_HDD_ID = [0] - def get_hdd_info(self) -> object: + def get_hdd_info(self) -> Dict: """ Gets all HDD and SD card information from Camera See examples/response/GetHddInfo.json for example response data. @@ -14,7 +14,7 @@ class DeviceAPIMixin: body = [{"cmd": "GetHddInfo", "action": 0, "param": {}}] return self._execute_command('GetHddInfo', body) - def format_hdd(self, hdd_id: List[int] = None) -> bool: + 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) diff --git a/reolink_api/display.py b/reolinkapi/display.py similarity index 96% rename from reolink_api/display.py rename to reolinkapi/display.py index 68fbb38..5c4c48c 100644 --- a/reolink_api/display.py +++ b/reolinkapi/display.py @@ -22,7 +22,7 @@ class DisplayAPIMixin: body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetMask', body) - def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, + 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") -> bool: """ diff --git a/reolink_api/download.py b/reolinkapi/download.py similarity index 100% rename from reolink_api/download.py rename to reolinkapi/download.py diff --git a/reolink_api/image.py b/reolinkapi/image.py similarity index 100% rename from reolink_api/image.py rename to reolinkapi/image.py diff --git a/reolink_api/motion.py b/reolinkapi/motion.py similarity index 97% rename from reolink_api/motion.py rename to reolinkapi/motion.py index 63a8ecf..b95746d 100644 --- a/reolink_api/motion.py +++ b/reolinkapi/motion.py @@ -3,7 +3,7 @@ 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, int, Dict[str, str]]]] +RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, float, Dict[str, str]]]] PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, dt]]] diff --git a/reolink_api/network.py b/reolinkapi/network.py similarity index 95% rename from reolink_api/network.py rename to reolinkapi/network.py index 8ec0cc3..f4fe4a6 100644 --- a/reolink_api/network.py +++ b/reolinkapi/network.py @@ -3,8 +3,8 @@ from typing import Dict class NetworkAPIMixin: """API calls for network settings.""" - def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: int = 9000, - onvif_port: int = 8000, rtmp_port: int = 1935, rtsp_port: int = 554) -> bool: + 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 diff --git a/reolink_api/ptz.py b/reolinkapi/ptz.py similarity index 100% rename from reolink_api/ptz.py rename to reolinkapi/ptz.py diff --git a/reolink_api/recording.py b/reolinkapi/recording.py similarity index 99% rename from reolink_api/recording.py rename to reolinkapi/recording.py index 2170e42..41284f2 100644 --- a/reolink_api/recording.py +++ b/reolinkapi/recording.py @@ -5,7 +5,7 @@ from urllib import parse from io import BytesIO from typing import Dict, Any, Optional from PIL.Image import Image, open as open_image -from reolink_api.rtsp_client import RtspClient +from reolinkapi.rtsp_client import RtspClient class RecordingAPIMixin: diff --git a/reolink_api/resthandle.py b/reolinkapi/resthandle.py similarity index 99% rename from reolink_api/resthandle.py rename to reolinkapi/resthandle.py index aa4704d..66f172a 100644 --- a/reolink_api/resthandle.py +++ b/reolinkapi/resthandle.py @@ -1,4 +1,3 @@ -import json import requests from typing import List, Dict, Union, Optional diff --git a/reolink_api/rtsp_client.py b/reolinkapi/rtsp_client.py similarity index 98% rename from reolink_api/rtsp_client.py rename to reolinkapi/rtsp_client.py index 6213ad4..0c1db0e 100644 --- a/reolink_api/rtsp_client.py +++ b/reolinkapi/rtsp_client.py @@ -2,7 +2,7 @@ import os from threading import ThreadError from typing import Any import cv2 -from reolink_api.util import threaded +from reolinkapi.util import threaded class RtspClient: diff --git a/reolink_api/system.py b/reolinkapi/system.py similarity index 100% rename from reolink_api/system.py rename to reolinkapi/system.py diff --git a/reolink_api/user.py b/reolinkapi/user.py similarity index 100% rename from reolink_api/user.py rename to reolinkapi/user.py diff --git a/reolink_api/util.py b/reolinkapi/util.py similarity index 100% rename from reolink_api/util.py rename to reolinkapi/util.py diff --git a/reolink_api/zoom.py b/reolinkapi/zoom.py similarity index 100% rename from reolink_api/zoom.py rename to reolinkapi/zoom.py From 4c4dd7dd697f182f163300e7cc82cfa41e0c5b53 Mon Sep 17 00:00:00 2001 From: Bobrock Date: Fri, 18 Dec 2020 15:58:28 -0600 Subject: [PATCH 12/13] Complete second logic pass, remove underscore from package name --- examples/download_motions.py | 2 +- examples/streaming_video.py | 2 +- setup.py | 4 ++-- tests/test_camera.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/download_motions.py b/examples/download_motions.py index 66d92da..59ec181 100644 --- a/examples/download_motions.py +++ b/examples/download_motions.py @@ -2,7 +2,7 @@ import os from configparser import RawConfigParser from datetime import datetime as dt, timedelta -from reolink_api import Camera +from reolinkapi import Camera def read_config(props_path: str) -> dict: diff --git a/examples/streaming_video.py b/examples/streaming_video.py index 8f0a01b..9049ed8 100644 --- a/examples/streaming_video.py +++ b/examples/streaming_video.py @@ -1,5 +1,5 @@ import cv2 -from reolink_api import Camera +from reolinkapi import Camera def non_blocking(): diff --git a/setup.py b/setup.py index 5a7c521..500a40f 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def find_version(*file_paths): # Package meta-data. -NAME = 'reolink_api' +NAME = 'reolinkapi' DESCRIPTION = 'Reolink Camera API written in Python 3.6' URL = 'https://github.com/Benehiko/ReolinkCameraAPI' AUTHOR_EMAIL = '' @@ -44,7 +44,7 @@ with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: setup( name=NAME, python_requires='>=3.6.0', - version=find_version('reolink_api', '__init__.py'), + version=find_version('reolinkapi', '__init__.py'), description=DESCRIPTION, long_description=long_description, long_description_content_type='text/markdown', diff --git a/tests/test_camera.py b/tests/test_camera.py index 2fa5cf2..67851d0 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,7 +1,7 @@ import os from configparser import RawConfigParser import unittest -from reolink_api import Camera +from reolinkapi import Camera def read_config(props_path: str) -> dict: From 2b3e142fe500a9b71ba57639d418b109b89bf648 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Sat, 19 Dec 2020 19:55:12 +0200 Subject: [PATCH 13/13] Updated project structure and some file names. Restored `requirements.txt` Updated `setup.py` to include new repository url and contact details. Moved the rtsp code from `record` to `stream`. Updated project structure to make it more readable and developer friendly - moved mixins to the `mixins` package, moved handlers to the `handlers` package. Moved files not belonging to anything in particular to the `util` package. Updated `camera` class to also defer login call. Deleted unused files like `config_handler`. --- README.md | 6 +-- examples/basic_usage.py | 11 ++++ reolinkapi/__init__.py | 2 +- reolinkapi/camera.py | 22 ++++++-- reolinkapi/config_handler.py | 17 ------ reolinkapi/handlers/__init__.py | 0 reolinkapi/{ => handlers}/api_handler.py | 33 ++++++------ .../rest_handler.py} | 0 reolinkapi/mixins/__init__.py | 0 reolinkapi/{ => mixins}/alarm.py | 0 reolinkapi/{ => mixins}/device.py | 0 reolinkapi/{ => mixins}/display.py | 0 reolinkapi/{ => mixins}/download.py | 0 reolinkapi/{ => mixins}/image.py | 0 reolinkapi/{ => mixins}/motion.py | 0 reolinkapi/{ => mixins}/network.py | 0 reolinkapi/{ => mixins}/ptz.py | 0 reolinkapi/{recording.py => mixins/record.py} | 54 ++----------------- reolinkapi/mixins/stream.py | 52 ++++++++++++++++++ reolinkapi/{ => mixins}/system.py | 0 reolinkapi/{ => mixins}/user.py | 0 reolinkapi/{ => mixins}/zoom.py | 0 reolinkapi/utils/__init__.py | 0 reolinkapi/{ => utils}/rtsp_client.py | 3 +- reolinkapi/{ => utils}/util.py | 0 requirements.txt | 6 +++ setup.py | 6 +-- 27 files changed, 117 insertions(+), 95 deletions(-) create mode 100644 examples/basic_usage.py delete mode 100644 reolinkapi/config_handler.py create mode 100644 reolinkapi/handlers/__init__.py rename reolinkapi/{ => handlers}/api_handler.py (84%) rename reolinkapi/{resthandle.py => handlers/rest_handler.py} (100%) create mode 100644 reolinkapi/mixins/__init__.py rename reolinkapi/{ => mixins}/alarm.py (100%) rename reolinkapi/{ => mixins}/device.py (100%) rename reolinkapi/{ => mixins}/display.py (100%) rename reolinkapi/{ => mixins}/download.py (100%) rename reolinkapi/{ => mixins}/image.py (100%) rename reolinkapi/{ => mixins}/motion.py (100%) rename reolinkapi/{ => mixins}/network.py (100%) rename reolinkapi/{ => mixins}/ptz.py (100%) rename reolinkapi/{recording.py => mixins/record.py} (57%) create mode 100644 reolinkapi/mixins/stream.py rename reolinkapi/{ => mixins}/system.py (100%) rename reolinkapi/{ => mixins}/user.py (100%) rename reolinkapi/{ => mixins}/zoom.py (100%) create mode 100644 reolinkapi/utils/__init__.py rename reolinkapi/{ => utils}/rtsp_client.py (97%) rename reolinkapi/{ => utils}/util.py (100%) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 19332b7..4fcdd37 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@

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

diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..0ba744c --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,11 @@ +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() diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index e623a67..0a886c6 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,4 @@ -from .api_handler import APIHandler +from reolinkapi.handlers.api_handler import APIHandler from .camera import Camera __version__ = "0.1.2" diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py index 47db97e..98c208b 100644 --- a/reolinkapi/camera.py +++ b/reolinkapi/camera.py @@ -1,19 +1,31 @@ -from .api_handler import APIHandler +from reolinkapi.handlers.api_handler import APIHandler class Camera(APIHandler): - def __init__(self, ip: str, username: str = "admin", password: str = "", https: bool = False): + def __init__(self, ip: str, + username: str = "admin", + password: str = "", + https: bool = False, + defer_login: bool = False, + **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 """ # 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) + APIHandler.__init__(self, ip, username, password, https=https, **kwargs) # Normal call without proxy: # APIHandler.__init__(self, ip, username, password) @@ -21,4 +33,6 @@ class Camera(APIHandler): self.ip = ip self.username = username self.password = password - super().login() + + if not defer_login: + super().login() diff --git a/reolinkapi/config_handler.py b/reolinkapi/config_handler.py deleted file mode 100644 index a1c08ec..0000000 --- a/reolinkapi/config_handler.py +++ /dev/null @@ -1,17 +0,0 @@ -import io -import yaml -from typing import Optional, Dict - - -class ConfigHandler: - camera_settings = {} - - @staticmethod - def load() -> Optional[Dict]: - 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/reolinkapi/handlers/__init__.py b/reolinkapi/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/api_handler.py b/reolinkapi/handlers/api_handler.py similarity index 84% rename from reolinkapi/api_handler.py rename to reolinkapi/handlers/api_handler.py index 3ceae88..46d4637 100644 --- a/reolinkapi/api_handler.py +++ b/reolinkapi/handlers/api_handler.py @@ -1,18 +1,19 @@ import requests from typing import Dict, List, Optional, Union -from reolinkapi.alarm import AlarmAPIMixin -from reolinkapi.device import DeviceAPIMixin -from reolinkapi.display import DisplayAPIMixin -from reolinkapi.download import DownloadAPIMixin -from reolinkapi.image import ImageAPIMixin -from reolinkapi.motion import MotionAPIMixin -from reolinkapi.network import NetworkAPIMixin -from reolinkapi.ptz import PtzAPIMixin -from reolinkapi.recording import RecordingAPIMixin -from reolinkapi.resthandle import Request -from reolinkapi.system import SystemAPIMixin -from reolinkapi.user import UserAPIMixin -from reolinkapi.zoom import ZoomAPIMixin +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 class APIHandler(AlarmAPIMixin, @@ -23,10 +24,11 @@ class APIHandler(AlarmAPIMixin, MotionAPIMixin, NetworkAPIMixin, PtzAPIMixin, - RecordingAPIMixin, + RecordAPIMixin, SystemAPIMixin, UserAPIMixin, - ZoomAPIMixin): + ZoomAPIMixin, + StreamAPIMixin): """ The APIHandler class is the backend part of the API, the actual API calls are implemented in Mixins. @@ -42,6 +44,7 @@ class APIHandler(AlarmAPIMixin, :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 diff --git a/reolinkapi/resthandle.py b/reolinkapi/handlers/rest_handler.py similarity index 100% rename from reolinkapi/resthandle.py rename to reolinkapi/handlers/rest_handler.py diff --git a/reolinkapi/mixins/__init__.py b/reolinkapi/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/alarm.py b/reolinkapi/mixins/alarm.py similarity index 100% rename from reolinkapi/alarm.py rename to reolinkapi/mixins/alarm.py diff --git a/reolinkapi/device.py b/reolinkapi/mixins/device.py similarity index 100% rename from reolinkapi/device.py rename to reolinkapi/mixins/device.py diff --git a/reolinkapi/display.py b/reolinkapi/mixins/display.py similarity index 100% rename from reolinkapi/display.py rename to reolinkapi/mixins/display.py diff --git a/reolinkapi/download.py b/reolinkapi/mixins/download.py similarity index 100% rename from reolinkapi/download.py rename to reolinkapi/mixins/download.py diff --git a/reolinkapi/image.py b/reolinkapi/mixins/image.py similarity index 100% rename from reolinkapi/image.py rename to reolinkapi/mixins/image.py diff --git a/reolinkapi/motion.py b/reolinkapi/mixins/motion.py similarity index 100% rename from reolinkapi/motion.py rename to reolinkapi/mixins/motion.py diff --git a/reolinkapi/network.py b/reolinkapi/mixins/network.py similarity index 100% rename from reolinkapi/network.py rename to reolinkapi/mixins/network.py diff --git a/reolinkapi/ptz.py b/reolinkapi/mixins/ptz.py similarity index 100% rename from reolinkapi/ptz.py rename to reolinkapi/mixins/ptz.py diff --git a/reolinkapi/recording.py b/reolinkapi/mixins/record.py similarity index 57% rename from reolinkapi/recording.py rename to reolinkapi/mixins/record.py index 41284f2..375b5ca 100644 --- a/reolinkapi/recording.py +++ b/reolinkapi/mixins/record.py @@ -1,15 +1,8 @@ -import requests -import random -import string -from urllib import parse -from io import BytesIO -from typing import Dict, Any, Optional -from PIL.Image import Image, open as open_image -from reolinkapi.rtsp_client import RtspClient +from typing import Dict -class RecordingAPIMixin: - """API calls for recording/streaming image or video.""" +class RecordAPIMixin: + """API calls for the recording settings""" def get_recording_encoding(self) -> Dict: """ @@ -77,44 +70,3 @@ class RecordingAPIMixin: } ] return self._execute_command('SetEnc', body) - - ########### - # RTSP Stream - ########### - 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, 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(random.choices(string.ascii_uppercase + string.digits, k=10)), - 'user': self.username, - 'password': self.password, - } - parms = parse.urlencode(data).encode("utf-8") - - try: - response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout) - if response.status_code == 200: - return 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 diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py new file mode 100644 index 0000000..5d6e419 --- /dev/null +++ b/reolinkapi/mixins/stream.py @@ -0,0 +1,52 @@ +import string +from random import random +from typing import Any, Optional +from urllib import parse +from io import BytesIO + +import requests +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, 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(random.choices(string.ascii_uppercase + string.digits, k=10)), + 'user': self.username, + 'password': self.password, + } + parms = parse.urlencode(data).encode("utf-8") + + try: + response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout) + if response.status_code == 200: + return 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 diff --git a/reolinkapi/system.py b/reolinkapi/mixins/system.py similarity index 100% rename from reolinkapi/system.py rename to reolinkapi/mixins/system.py diff --git a/reolinkapi/user.py b/reolinkapi/mixins/user.py similarity index 100% rename from reolinkapi/user.py rename to reolinkapi/mixins/user.py diff --git a/reolinkapi/zoom.py b/reolinkapi/mixins/zoom.py similarity index 100% rename from reolinkapi/zoom.py rename to reolinkapi/mixins/zoom.py diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reolinkapi/rtsp_client.py b/reolinkapi/utils/rtsp_client.py similarity index 97% rename from reolinkapi/rtsp_client.py rename to reolinkapi/utils/rtsp_client.py index 0c1db0e..e260a74 100644 --- a/reolinkapi/rtsp_client.py +++ b/reolinkapi/utils/rtsp_client.py @@ -2,11 +2,12 @@ import os from threading import ThreadError from typing import Any import cv2 -from reolinkapi.util import threaded +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 diff --git a/reolinkapi/util.py b/reolinkapi/utils/util.py similarity index 100% rename from reolinkapi/util.py rename to reolinkapi/utils/util.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a8aabcd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +numpy==1.19.4 +opencv-python==4.4.0.46 +Pillow==8.0.1 +PySocks==1.7.1 +PyYaml==5.3.1 +requests>=2.18.4 \ No newline at end of file diff --git a/setup.py b/setup.py index 500a40f..3764023 100644 --- a/setup.py +++ b/setup.py @@ -20,9 +20,9 @@ def find_version(*file_paths): # Package meta-data. NAME = 'reolinkapi' -DESCRIPTION = 'Reolink Camera API written in Python 3.6' -URL = 'https://github.com/Benehiko/ReolinkCameraAPI' -AUTHOR_EMAIL = '' +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 = [