diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml new file mode 100644 index 0000000..a3e903c --- /dev/null +++ b/.github/workflows/python-package.yaml @@ -0,0 +1,43 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest build + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Build + run: | + python -m build + # - name: Test with pytest + # run: | + # pytest diff --git a/.gitignore b/.gitignore index 368d535..c0c4d43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +secrets.cfg # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -58,3 +59,4 @@ docs/_build/ target/ .idea/ +venv/ \ No newline at end of file diff --git a/Camera.py b/Camera.py deleted file mode 100644 index a60490a..0000000 --- a/Camera.py +++ /dev/null @@ -1,24 +0,0 @@ -from api import APIHandler - - -class Camera(APIHandler): - - def __init__(self, ip, username="admin", password="", https=False): - """ - Initialise the Camera object by passing the ip address. - The default details {"username":"admin", "password":""} will be used if nothing passed - :param ip: - :param username: - :param password: - """ - # For when you need to connect to a camera behind a proxy, pass - # a proxy argument: proxy={"http": "socks5://127.0.0.1:8000"} - APIHandler.__init__(self, ip, username, password, https=https) - - # Normal call without proxy: - # APIHandler.__init__(self, ip, username, password) - - self.ip = ip - self.username = username - self.password = password - super().login() diff --git a/ConfigHandler.py b/ConfigHandler.py deleted file mode 100644 index 67e8d62..0000000 --- a/ConfigHandler.py +++ /dev/null @@ -1,17 +0,0 @@ -import io - -import yaml - - -class ConfigHandler: - camera_settings = {} - - @staticmethod - def load() -> yaml or None: - try: - stream = io.open("config.yml", 'r', encoding='utf8') - data = yaml.safe_load(stream) - return data - except Exception as e: - print("Config Property Error\n", e) - return None diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 15b8698..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,169 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "6700bce6ed08db166eff9d3105158923ffd2ffbf35c814a4d0133552bda03b5a" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "idna": { - "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" - ], - "version": "==2.9" - }, - "numpy": { - "hashes": [ - "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6", - "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e", - "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc", - "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc", - "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a", - "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa", - "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3", - "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121", - "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971", - "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26", - "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd", - "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480", - "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec", - "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77", - "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57", - "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07", - "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572", - "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73", - "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca", - "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474", - "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5" - ], - "index": "pypi", - "version": "==1.18.1" - }, - "opencv-python": { - "hashes": [ - "sha256:0f2e739c582e8c5e432130648bc6d66a56bc65f4cd9ff0bc7033033d2130c7a3", - "sha256:0f3d159ad6cb9cbd188c726f87485f0799a067a0a15f34c25d7b5c8db3cb2e50", - "sha256:167a6aff9bd124a3a67e0ec25d0da5ecdc8d96a56405e3e5e7d586c4105eb1bb", - "sha256:1b90d50bc7a31e9573a8da1b80fcd1e4d9c86c0e5f76387858e1b87eb8b0332b", - "sha256:2baf1213ae2fd678991f905d7b2b94eddfdfb5f75757db0f0b31eebd48ca200d", - "sha256:312dda54c7e809c20d7409418060ae0e9cdbe82975e7ced429eb3c234ffc0d4a", - "sha256:32384e675f7cefe707cac40a95eeb142d6869065e39c5500374116297cd8ca6d", - "sha256:5c50634dd8f2f866fd99fd939292ce10e52bef82804ebc4e7f915221c3b7e951", - "sha256:6841bb9cc24751dbdf94e7eefc4e6d70ec297952501954471299fd12ab67391c", - "sha256:68c1c846dd267cd7e293d3fc0bb238db0a744aa1f2e721e327598f00cb982098", - "sha256:703910aaa1dcd25a412f78a190fb7a352d9a64ee7d9a35566d786f3cc66ebf20", - "sha256:8002959146ed21959e3118c60c8e94ceac02eea15b691da6c62cff4787c63f7f", - "sha256:889eef049d38488b5b4646c48a831feed37c0fd44f3d83c05cff80f4baded145", - "sha256:8c76983c9ec3e4cf3a4c1d172ec4285332d9fb1c7194d724aff0c518437471ee", - "sha256:9cd9bd72f4a9743ef6f11f0f96784bd215a542e996db1717d4c2d3d03eb81a1b", - "sha256:a1a5517301dc8d56243a14253d231ec755b94486b4fff2ae68269bc941bb1f2e", - "sha256:a2b08aec2eacae868723136383d9eb84a33062a7a7ec5ec3bd2c423bd1355946", - "sha256:a8529a79233f3581a66984acd16bce52ab0163f6f77568dd69e9ee4956d2e1db", - "sha256:afbc81a3870739610a9f9a1197374d6a45892cf1933c90fc5617d39790991ed3", - "sha256:baeb5dd8b21c718580687f5b4efd03f8139b1c56239cdf6b9805c6946e80f268", - "sha256:db1d49b753e6e6c76585f21d09c7e9812176732baa9bddb64bc2fc6cd24d4179", - "sha256:e242ed419aeb2488e0f9ee6410a34917f0f8d62b3ae96aa3170d83bae75004e2", - "sha256:e36a8857be2c849e54009f1bee25e8c34fbc683fcd38c6c700af4cba5f8d57c2", - "sha256:e699232fd033ef0053efec2cba0a7505514f374ba7b18c732a77cb5304311ef9", - "sha256:eae3da9231d87980f8082d181c276a04f7a6fdac130cebd467390b96dd05f944", - "sha256:ee6814c94dbf1cae569302afef9dd29efafc52373e8770ded0db549a3b6e0c00", - "sha256:f01a87a015227d8af407161eb48222fc3c8b01661cdc841e2b86eee4f1a7a417" - ], - "index": "pypi", - "version": "==4.2.0.32" - }, - "pillow": { - "hashes": [ - "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", - "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", - "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", - "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", - "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", - "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", - "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", - "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", - "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", - "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", - "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", - "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", - "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", - "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", - "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", - "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", - "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", - "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", - "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", - "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", - "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", - "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" - ], - "index": "pypi", - "version": "==7.0.0" - }, - "pysocks": { - "hashes": [ - "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", - "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", - "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" - ], - "index": "pypi", - "version": "==1.7.1" - }, - "pyyaml": { - "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" - ], - "index": "pypi", - "version": "==5.3" - }, - "requests": { - "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" - ], - "index": "pypi", - "version": "==2.23.0" - }, - "urllib3": { - "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" - ], - "version": "==1.25.8" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index cd90018..87a205f 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,88 @@ -## ReolinkCameraAPI +

Reolink Python Api Client

-### Join us on Discord +

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

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