From 1c593a0ad18813df8f45cb8e94deca34cb471b08 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Wed, 23 Dec 2020 12:05:42 +0200 Subject: [PATCH 1/2] Adding camera discovery utilities camera discovery using UPnP and port scanning. camera factory can now manage those discovered cameras for the user. camera also now has logged_in property to check if the camera has a token or not. updated basic usage and readme to document the changes/improvements. --- README.md | 44 ++++++++++- examples/basic_usage.py | 28 +++++++ reolinkapi/__init__.py | 2 + reolinkapi/camera.py | 5 ++ reolinkapi/mixins/stream.py | 2 +- reolinkapi/utils/__init__.py | 3 + reolinkapi/utils/camera_factory.py | 44 +++++++++++ reolinkapi/utils/discover.py | 116 +++++++++++++++++++++++++++++ setup.py | 4 +- 9 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 reolinkapi/utils/camera_factory.py create mode 100644 reolinkapi/utils/discover.py diff --git a/README.md b/README.md index 802c11d..2100086 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Implement a "Camera" object by passing it an IP address, Username and Password. See the `examples` directory. -### Using the library as a Python Module +### Installation Install the package via PyPi @@ -49,6 +49,48 @@ Install from GitHub pip install git+https://github.com/ReolinkCameraAPI/reolinkapipy.git +### Usage + +```python + +import reolinkapi + +if __name__ == "__main__": + # this will immediately log in with default camera credentials + cam = reolinkapi.Camera("192.168.0.100") + + # OR in the case of managing a pool of cameras' - it's better to defer login + # foo is the username + # bar is the password + _ = reolinkapi.Camera("192.168.0.100", "foo", "bar", defer_login = True) + + # to scan your network for reolink cameras + + # when UPnP is enabled on the camera, simply use: + discovery = reolinkapi.Discover() + devices = discovery.discover_upnp() + + # OR + _ = discovery.discover_port() + + # when many cameras share the same credentials + # foo is the username + # bar is the password + factory = reolinkapi.CameraFactory(username="foo", password="bar") + _ = factory.get_cameras_from_devices(devices) + + # when using the CameraFactory, we need to log in manually on each camera + # since it creates a pool of cameras + # one can use the utility function in camera factory to run an asyncio task + factory.initialise_cameras() + + # now one can check if the camera has been initialised + for camera in factory.cameras: + if camera.is_loggedin(): + print(f'Camera {camera.ip} is logged in') + else: + print(f'Camera {camera.ip} is NOT logged in') +``` ## Contributors --- diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 0ba744c..e8c9781 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,11 +1,39 @@ import reolinkapi if __name__ == "__main__": + # create a single camera + # defer_login is optional, if nothing is passed it will attempt to log in. cam = reolinkapi.Camera("192.168.0.102", defer_login=True) # must first login since I defer have deferred the login process cam.login() + # can now use the camera api dst = cam.get_dst() ok = cam.add_user("foo", "bar", "admin") alarm = cam.get_alarm_motion() + + # discover cameras on the network + discovery = reolinkapi.Discover() + + # can use upnp + devices = discovery.discover_upnp() + + # or port scanning using default ports (can set this when setting up Discover object + devices = discovery.discover_port() + + # create a camera factory, these will authenticate every camera with the same username and password + factory = reolinkapi.CameraFactory(username="foo", password="bar") + + # create your camera factory, can immediately return the cameras or just keep it inside the factory + _ = factory.get_cameras_from_devices(devices=devices) + + # initialise the cameras (log them in) + factory.initialise_cameras() + + # one can check if the camera is authenticated with it's `is_loggedin` property + for camera in factory.cameras: + if camera.is_loggedin: + print(f'camera {camera.ip} is logged in') + else: + print(f'camera {camera.ip} is not logged in') diff --git a/reolinkapi/__init__.py b/reolinkapi/__init__.py index 0a886c6..3d925cd 100644 --- a/reolinkapi/__init__.py +++ b/reolinkapi/__init__.py @@ -1,4 +1,6 @@ from reolinkapi.handlers.api_handler import APIHandler +from reolinkapi.utils.camera_factory import CameraFactory +from reolinkapi.utils.discover import Discover from .camera import Camera __version__ = "0.1.2" diff --git a/reolinkapi/camera.py b/reolinkapi/camera.py index 98c208b..b2219a2 100644 --- a/reolinkapi/camera.py +++ b/reolinkapi/camera.py @@ -36,3 +36,8 @@ def __init__(self, ip: str, if not defer_login: super().login() + + @property + def is_loggedin(self): + return self.token is not None + diff --git a/reolinkapi/mixins/stream.py b/reolinkapi/mixins/stream.py index 5d6e419..a15ca96 100644 --- a/reolinkapi/mixins/stream.py +++ b/reolinkapi/mixins/stream.py @@ -7,7 +7,7 @@ import requests from PIL.Image import Image, open as open_image -from reolinkapi.utils.rtsp_client import RtspClient +from reolinkapi.utils import RtspClient class StreamAPIMixin: diff --git a/reolinkapi/utils/__init__.py b/reolinkapi/utils/__init__.py index e69de29..043b462 100644 --- a/reolinkapi/utils/__init__.py +++ b/reolinkapi/utils/__init__.py @@ -0,0 +1,3 @@ +from .discover import Discover +from .rtsp_client import RtspClient +from .camera_factory import CameraFactory diff --git a/reolinkapi/utils/camera_factory.py b/reolinkapi/utils/camera_factory.py new file mode 100644 index 0000000..9719ef3 --- /dev/null +++ b/reolinkapi/utils/camera_factory.py @@ -0,0 +1,44 @@ +import asyncio +from typing import List + +from pyphorus.devices import Device + +from reolinkapi import Camera + + +class CameraFactory: + + def __init__(self, username="admin", password=""): + self._username = username + self._password = password + self._cameras = [] + + @property + def cameras(self): + return self._cameras + + def get_cameras_from_devices(self, devices: List[Device]) -> List[Camera]: + # only get the ip from each device + + ips = [] + for device in devices: + ips.append(device.ip) + + for ip in ips: + self._cameras.append(Camera(ip, self._username, self._password, defer_login=True)) + + return self._cameras + + def initialise_cameras(self, timeout: int = 2): + if len(self._cameras) == 0: + raise Exception("there are no cameras in camera factory") + + async def _initialise_camera(): + tasks = [] + for camera in self._cameras: + tasks.append(asyncio.wait_for(camera.login(), timeout=timeout)) + + await asyncio.gather(*tasks) + + loop = asyncio.get_event_loop() + loop.run_until_complete(_initialise_camera()) diff --git a/reolinkapi/utils/discover.py b/reolinkapi/utils/discover.py new file mode 100644 index 0000000..7ac3168 --- /dev/null +++ b/reolinkapi/utils/discover.py @@ -0,0 +1,116 @@ +import socket +from typing import List + +import netaddr +import pyphorus +from pyphorus import Device + + +class Discover: + + def __init__(self, media_port: int = 9000, onvif_port: int = 8000, rtsp_port: int = 554, rtmp_port: int = 1935, + https_port: int = 443, http_port: int = 80, unique_device_ip: bool = True): + """ + Create a discover object. Pass custom port values to override the standard ones. + :param media_port: + :param onvif_port: + :param rtsp_port: + :param rtmp_port: + :param https_port: + :param http_port: + :param unique_device_ip: strip duplicate ips (basically ignoring the ports) + """ + self._unique_device_ip = unique_device_ip + self._media_port = media_port + self._onvif_port = onvif_port + self._rtsp_port = rtsp_port + self._rtmp_port = rtmp_port + self._https_port = https_port + self._http_port = http_port + + self._phorus = pyphorus.Pyphorus() + + self._devices = [] + + @property + def media_port(self): + return self._media_port + + @property + def onvif_port(self): + return self._onvif_port + + @property + def rtsp_port(self): + return self._rtsp_port + + @property + def rtmp_port(self): + return self._rtmp_port + + @property + def https_port(self): + return self._https_port + + @property + def http_port(self): + return self._http_port + + def list_standard_ports(self): + return { + "http": self._http_port, + "https": self._https_port, + "media": self._media_port, + "onvif": self._onvif_port, + "rtsp": self._rtsp_port, + "rtmp": self._rtmp_port, + } + + def discover_upnp(self) -> List[Device]: + """ + discover cameras using UPnP + this will only work if the camera has it enabled + :return: + """ + # TODO: unsure about the reolink upnp `st` + self._devices = self._phorus.scan_upnp("upnp:reolink") + + if self._unique_device_ip: + self._devices = pyphorus.utils.strip_duplicate_ips(devices=self._devices) + + return self._devices + + def discover_port(self, custom_cidr: str = None, additional_ports: List[int] = None) -> List[Device]: + """ + discover devices by scanning the network for open ports + this method will attempt at using the current machines' ip address to scan for open ports, to change + the ip address, change the custom_cidr field value + :param custom_cidr: cidr ip e.g. 192.168.0.0/24 + :param additional_ports: a list of additional ports to add [ 9000, 100000, ... ] to the standard or overridden + ports + :return: + """ + + if custom_cidr is None: + # attempt to get this machine's local ip + hostname = socket.gethostname() + ip_address = socket.gethostbyname(hostname) + # get the cidr from the ip address + cidr = netaddr.cidr_merge([ip_address]) + if len(cidr) > 0: + cidr = cidr[0] + custom_cidr = cidr + + custom_ports = [self._media_port, self._rtmp_port, self._rtsp_port, self._onvif_port, self._https_port, + self._http_port] + + if additional_ports is not None: + # use the cameras' standard ports + custom_ports += additional_ports + + self._devices = self._phorus.scan_ports(custom_cidr, ports=custom_ports) + + if self._unique_device_ip: + self._devices = pyphorus.utils.strip_duplicate_ips(devices=self._devices) + + return self._devices diff --git a/setup.py b/setup.py index 3764023..29ac315 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,9 @@ def find_version(*file_paths): 'Pillow==8.0.1', 'PySocks==1.7.1', 'PyYaml==5.3.1', - 'requests>=2.18.4', + 'requests>=2.25.1', + 'pyphorus==0.0.2', + 'netaddr>=0.8.0', ] From d56eed88be988872a5a7f1c525e38f0564a3bdf8 Mon Sep 17 00:00:00 2001 From: Alano Terblanche Date: Wed, 23 Dec 2020 13:56:45 +0200 Subject: [PATCH 2/2] pyphorus update v0.0.2 -> v0.0.3 (upnp xml parsing fixed) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 29ac315..d28a11f 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def find_version(*file_paths): 'PySocks==1.7.1', 'PyYaml==5.3.1', 'requests>=2.25.1', - 'pyphorus==0.0.2', + 'pyphorus==0.0.3', 'netaddr>=0.8.0', ]