From 8e80060da5f87ce8833be7e06adbcdcac9246c58 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sat, 17 Oct 2020 17:12:25 -0400 Subject: [PATCH 1/6] repo configuration --- .pre-commit-config.yaml | 47 +++++++++++++++++++++++++++++++++++++++++ Makefile | 39 ++++++++++++++++++++++++++++++++++ onvif/client.py | 13 ++++++------ pyproject.toml | 3 +++ requirements.txt | 18 ++++++++++++++++ setup.cfg | 23 ++++++++++++++++++++ setup.py | 7 +++--- 7 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..45a8d16 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.7.2 + hooks: + - id: pyupgrade + args: [--py36-plus] + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((xbox|tests)/.+)?[^/]+\.py$ + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.3 + hooks: + - id: flake8 + additional_dependencies: + # - flake8-docstrings==1.5.0 + - pydocstyle==5.1.1 + files: ^(xbox)/.+\.py$ + - repo: https://github.com/PyCQA/bandit + rev: 1.6.2 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=bandit.yaml + files: ^(xbox|tests)/.+\.py$ + - repo: https://github.com/PyCQA/isort + rev: 5.5.3 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + - repo: https://github.com/prettier/prettier + rev: 2.0.4 + hooks: + - id: prettier + stages: [manual] + \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6543865 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts + +clean-build: ## remove build artifacts + rm -fr build/ + rm -fr dist/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -fr {} + + +clean-pyc: ## remove Python file artifacts + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## remove test and coverage artifacts + rm -fr .tox/ + rm -f .coverage + rm -fr htmlcov/ + +lint: ## check style with flake8 + flake8 onvif tests + pylint onvif + +test: ## run tests quickly with the default Python + pytest --cov=onvif --cov-report html tests/ + +release: clean ## package and upload a release + python3 -m twine upload dist/* + +dist: clean ## builds source and wheel package + python setup.py sdist + python setup.py bdist_wheel + ls -l dist + +install: clean ## install the package to the active Python's site-packages + pip3 install -r requirements.txt + pre-commit install + pip3 install -e . diff --git a/onvif/client.py b/onvif/client.py index 31c3544..2a7e69a 100644 --- a/onvif/client.py +++ b/onvif/client.py @@ -1,19 +1,19 @@ """ONVIF Client.""" import datetime as dt -import os.path import logging +import os.path from aiohttp import ClientSession from zeep.asyncio import AsyncTransport from zeep.cache import SqliteCache -from zeep.client import Client, CachingClient, Settings +from zeep.client import CachingClient, Client, Settings from zeep.exceptions import Fault -from zeep.wsse.username import UsernameToken import zeep.helpers +from zeep.wsse.username import UsernameToken -from onvif.exceptions import ONVIFError from onvif.definition import SERVICES +from onvif.exceptions import ONVIFError logger = logging.getLogger("onvif") logging.basicConfig(level=logging.INFO) @@ -211,6 +211,7 @@ class ONVIFCamera: # Another way: >>> ptz_service.GetConfiguration() """ + def __init__( self, host, @@ -297,7 +298,7 @@ def get_definition(self, name, port_type=None): wsdl_file = SERVICES[name]["wsdl"] namespace = SERVICES[name]["ns"] - binding_name = "{%s}%s" % (namespace, SERVICES[name]["binding"]) + binding_name = "{{{}}}{}".format(namespace, SERVICES[name]["binding"]) if port_type: namespace += "/" + port_type @@ -308,7 +309,7 @@ def get_definition(self, name, port_type=None): # XAddr for devicemgmt is fixed: if name == "devicemgmt": - xaddr = "%s:%s/onvif/device_service" % ( + xaddr = "{}:{}/onvif/device_service".format( self.host if (self.host.startswith("http://") or self.host.startswith("https://")) else "http://%s" % self.host, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a75060 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +target-version = ["py36", "py37", "py38"] +exclude = 'generated' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..612d779 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# Package +httpx==0.16.1 +zeep[async]==4.0.0 + +# Dev +pytest +pytest-cov +pylint + +# pre-commit +pre-commit==2.7.1 +pyupgrade==2.7.2 +black==20.8b1 +flake8==3.8.3 +# flake8-docstrings==1.5.0 +pydocstyle==5.1.1 +bandit==1.6.2 +isort==5.5.3 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 5aef279..54b52ea 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,25 @@ [metadata] description-file = README.rst + +[bdist_wheel] +universal = 1 + +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build,docs +max-line-length = 88 +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +profile = black +force_sort_within_sections = true +known_first_party = xbox,tests +forced_separate = tests +combine_as_imports = true + +[aliases] +test = pytest diff --git a/setup.py b/setup.py index 93a1cd3..9c7304a 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,13 @@ """Package Setup.""" import os -from setuptools import setup, find_packages + +from setuptools import find_packages, setup here = os.path.abspath(os.path.dirname(__file__)) version_path = os.path.join(here, "onvif/version.txt") version = open(version_path).read().strip() -requires = ["zeep[async]==3.4.0", "aiohttp>=1.0"] +requires = ["httpx==0.16.1", "zeep[async]==4.0.0"] CLASSIFIERS = [ "Development Status :: 3 - Alpha", @@ -30,7 +31,7 @@ name="onvif-zeep-async", version=version, description="Async Python Client for ONVIF Camera", - long_description=open("README.rst", "r").read(), + long_description=open("README.rst").read(), author="Cherish Chen", author_email="sinchb128@gmail.com", maintainer="sinchb", From a382c66cc4a789cb413c5e5a2ff9d529eb97a558 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sat, 17 Oct 2020 18:06:45 -0400 Subject: [PATCH 2/6] remove outdated examples and tests, upgrade to zeep 4.0 --- bandit.yaml | 17 +++ examples/continuous_move.py | 159 ------------------------- examples/events.py | 14 ++- examples/rotate_image.py | 14 ++- examples/streaming.py | 45 ++++--- onvif/cli.py | 215 --------------------------------- onvif/client.py | 49 ++++++-- onvif/version.txt | 2 +- tests/test.py | 229 ++++++++++++++++++------------------ 9 files changed, 216 insertions(+), 528 deletions(-) create mode 100644 bandit.yaml delete mode 100644 examples/continuous_move.py delete mode 100644 onvif/cli.py diff --git a/bandit.yaml b/bandit.yaml new file mode 100644 index 0000000..ebd284e --- /dev/null +++ b/bandit.yaml @@ -0,0 +1,17 @@ +# https://bandit.readthedocs.io/en/latest/config.html + +tests: + - B108 + - B306 + - B307 + - B313 + - B314 + - B315 + - B316 + - B317 + - B318 + - B319 + - B320 + - B325 + - B602 + - B604 diff --git a/examples/continuous_move.py b/examples/continuous_move.py deleted file mode 100644 index e4e2094..0000000 --- a/examples/continuous_move.py +++ /dev/null @@ -1,159 +0,0 @@ -import asyncio, sys -from onvif import ONVIFCamera - -IP="192.168.0.100" # Camera IP address -PORT=10080 # Port -USER="admin" # Username -PASS="password" # Password - - -XMAX = 1 -XMIN = -1 -YMAX = 1 -YMIN = -1 -moverequest = None -ptz = None -active = False - -async def do_move(ptz, request): - # Start continuous move - global active - if active: - await ptz.Stop({'ProfileToken': request.ProfileToken}) - active = True - await ptz.ContinuousMove(request) - -async def move_up(ptz, request): - print ('move up...') - request.Velocity.PanTilt.x = 0 - request.Velocity.PanTilt.y = YMAX - await do_move(ptz, request) - -async def move_down(ptz, request): - print ('move down...') - request.Velocity.PanTilt.x = 0 - request.Velocity.PanTilt.y = YMIN - await do_move(ptz, request) - -async def move_right(ptz, request): - print ('move right...') - request.Velocity.PanTilt.x = XMAX - request.Velocity.PanTilt.y = 0 - await do_move(ptz, request) - -async def move_left(ptz, request): - print ('move left...') - request.Velocity.PanTilt.x = XMIN - request.Velocity.PanTilt.y = 0 - await do_move(ptz, request) - - -async def move_upleft(ptz, request): - print ('move up left...') - request.Velocity.PanTilt.x = XMIN - request.Velocity.PanTilt.y = YMAX - await do_move(ptz, request) - -async def move_upright(ptz, request): - print ('move up left...') - request.Velocity.PanTilt.x = XMAX - request.Velocity.PanTilt.y = YMAX - await do_move(ptz, request) - -async def move_downleft(ptz, request): - print ('move down left...') - request.Velocity.PanTilt.x = XMIN - request.Velocity.PanTilt.y = YMIN - await do_move(ptz, request) - -async def move_downright(ptz, request): - print ('move down left...') - request.Velocity.PanTilt.x = XMAX - request.Velocity.PanTilt.y = YMIN - await do_move(ptz, request) - -async def setup_move(): - mycam = ONVIFCamera(IP, PORT, USER, PASS) - await mycam.update_xaddrs() - # Create media service object - media = mycam.create_media_service() - - # Create ptz service object - global ptz - ptz = mycam.create_ptz_service() - - # Get target profile - media_profile = await media.GetProfiles()[0] - - # Get PTZ configuration options for getting continuous move range - request = ptz.create_type('GetConfigurationOptions') - request.ConfigurationToken = media_profile.PTZConfiguration.token - ptz_configuration_options = await ptz.GetConfigurationOptions(request) - - global moverequest - moverequest = ptz.create_type('ContinuousMove') - moverequest.ProfileToken = media_profile.token - if moverequest.Velocity is None: - moverequest.Velocity = await ptz.GetStatus({'ProfileToken': media_profile.token}).Position - - - # Get range of pan and tilt - # NOTE: X and Y are velocity vector - global XMAX, XMIN, YMAX, YMIN - XMAX = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].XRange.Max - XMIN = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].XRange.Min - YMAX = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].YRange.Max - YMIN = ptz_configuration_options.Spaces.ContinuousPanTiltVelocitySpace[0].YRange.Min - - -def readin(): - """Reading from stdin and displaying menu""" - global moverequest, ptz - - selection = sys.stdin.readline().strip("\n") - lov=[ x for x in selection.split(" ") if x != ""] - if lov: - loop = asyncio.get_event_loop() - if lov[0].lower() in ["u","up"]: - coro = move_up(ptz,moverequest) - elif lov[0].lower() in ["d","do","dow","down"]: - coro = move_down(ptz,moverequest) - elif lov[0].lower() in ["l","le","lef","left"]: - coro = move_left(ptz,moverequest) - elif lov[0].lower() in ["l","le","lef","left"]: - coro = move_left(ptz,moverequest) - elif lov[0].lower() in ["r","ri","rig","righ","right"]: - coro = move_right(ptz,moverequest) - elif lov[0].lower() in ["ul"]: - coro = move_upleft(ptz,moverequest) - elif lov[0].lower() in ["ur"]: - coro = move_upright(ptz,moverequest) - elif lov[0].lower() in ["dl"]: - coro = move_downleft(ptz,moverequest) - elif lov[0].lower() in ["dr"]: - coro = move_downright(ptz,moverequest) - elif lov[0].lower() in ["s","st","sto","stop"]: - coro = ptz.Stop({'ProfileToken': moverequest.ProfileToken}) - active = False - else: - print("What are you asking?\tI only know, 'up','down','left','right', 'ul' (up left), \n\t\t\t'ur' (up right), 'dl' (down left), 'dr' (down right) and 'stop'") - - if coro: - loop.call_soon(coro) - print("") - print("Your command: ", end='',flush=True) - - -if __name__ == '__main__': - setup_move() - loop = asyncio.get_event_loop() - try: - loop.add_reader(sys.stdin,readin) - print("Use Ctrl-C to quit") - print("Your command: ", end='',flush=True) - loop.run_forever() - except: - pass - finally: - loop.remove_reader(sys.stdin) - loop.close() diff --git a/examples/events.py b/examples/events.py index 8d2f39e..bcddf9f 100644 --- a/examples/events.py +++ b/examples/events.py @@ -1,8 +1,9 @@ """Example to fetch pullpoint events.""" import asyncio import datetime as dt -from pytz import UTC import logging + +from pytz import UTC from zeep import xsd from onvif import ONVIFCamera @@ -24,7 +25,7 @@ async def run(): print("PullPoint not supported") return - event_service = mycam.get_service("events") + event_service = mycam.create_events_service() properties = await event_service.GetEventProperties() print(properties) capabilities = await event_service.GetServiceCapabilities() @@ -39,11 +40,14 @@ async def run(): print(messages) subscription = mycam.create_subscription_service("PullPointSubscription") - # req = subscription.zeep_client.get_element("ns5:Renew") - # req.TerminationTime = str(dt.datetime.now(UTC) + dt.timedelta(minutes=10)) - termination_time = (dt.datetime.now(UTC) + dt.timedelta(minutes=10)).isoformat() + termination_time = ( + (dt.datetime.utcnow() + dt.timedelta(days=1)) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) await subscription.Renew(termination_time) await subscription.Unsubscribe() + await mycam.close() if __name__ == "__main__": diff --git a/examples/rotate_image.py b/examples/rotate_image.py index d921974..061ceca 100644 --- a/examples/rotate_image.py +++ b/examples/rotate_image.py @@ -1,11 +1,13 @@ import asyncio + from onvif import ONVIFCamera + async def rotate_image_180(): - ''' Rotate the image ''' + """ Rotate the image """ # Create the media service - mycam = ONVIFCamera('192.168.0.112', 80, 'admin', '12345') + mycam = ONVIFCamera("192.168.0.112", 80, "admin", "12345") await mycam.update_xaddrs() media_service = mycam.create_media_service() @@ -21,10 +23,10 @@ async def rotate_image_180(): video_source_configuration = configurations_list[0] # Enable rotate - video_source_configuration.Extension[0].Rotate[0].Mode[0] = 'OFF' + video_source_configuration.Extension[0].Rotate[0].Mode[0] = "OFF" # Create request type instance - request = media_service.create_type('SetVideoSourceConfiguration') + request = media_service.create_type("SetVideoSourceConfiguration") request.Configuration = video_source_configuration # ForcePersistence is obsolete and should always be assumed to be True @@ -32,7 +34,9 @@ async def rotate_image_180(): # Set the video source configuration await media_service.SetVideoSourceConfiguration(request) + await mycam.close() + -if __name__ == '__main__': +if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(rotate_image_180()) diff --git a/examples/streaming.py b/examples/streaming.py index 91a18c6..2b96cc6 100644 --- a/examples/streaming.py +++ b/examples/streaming.py @@ -1,16 +1,18 @@ import asyncio + from onvif import ONVIFCamera + async def media_profile_configuration(): - ''' + """ A media profile consists of configuration entities such as video/audio source configuration, video/audio encoder configuration, or PTZ configuration. This use case describes how to change one configuration entity which has been already added to the media profile. - ''' + """ # Create the media service - mycam = ONVIFCamera('192.168.0.112', 80, 'admin', '12345') + mycam = ONVIFCamera("192.168.0.112", 80, "admin", "12345") await mycam.update_xaddrs() media_service = mycam.create_media_service() @@ -26,36 +28,45 @@ async def media_profile_configuration(): video_encoder_configuration = configurations_list[0] # Get video encoder configuration options - options = await media_service.GetVideoEncoderConfigurationOptions({'ProfileToken':token}) + options = await media_service.GetVideoEncoderConfigurationOptions( + {"ProfileToken": token} + ) # Setup stream configuration - video_encoder_configuration.Encoding = 'H264' + video_encoder_configuration.Encoding = "H264" # Setup Resolution - video_encoder_configuration.Resolution.Width = \ - options.H264.ResolutionsAvailable[0].Width - video_encoder_configuration.Resolution.Height = \ - options.H264.ResolutionsAvailable[0].Height + video_encoder_configuration.Resolution.Width = options.H264.ResolutionsAvailable[ + 0 + ].Width + video_encoder_configuration.Resolution.Height = options.H264.ResolutionsAvailable[ + 0 + ].Height # Setup Quality video_encoder_configuration.Quality = options.QualityRange.Min # Setup FramRate - video_encoder_configuration.RateControl.FrameRateLimit = \ - options.H264.FrameRateRange.Min + video_encoder_configuration.RateControl.FrameRateLimit = ( + options.H264.FrameRateRange.Min + ) # Setup EncodingInterval - video_encoder_configuration.RateControl.EncodingInterval = \ - options.H264.EncodingIntervalRange.Min + video_encoder_configuration.RateControl.EncodingInterval = ( + options.H264.EncodingIntervalRange.Min + ) # Setup Bitrate - video_encoder_configuration.RateControl.BitrateLimit = \ - options.Extension.H264[0].BitrateRange[0].Min[0] + video_encoder_configuration.RateControl.BitrateLimit = ( + options.Extension.H264[0].BitrateRange[0].Min[0] + ) # Create request type instance - request = media_service.create_type('SetVideoEncoderConfiguration') + request = media_service.create_type("SetVideoEncoderConfiguration") request.Configuration = video_encoder_configuration # ForcePersistence is obsolete and should always be assumed to be True request.ForcePersistence = True # Set the video encoder configuration # await media_service.SetVideoEncoderConfiguration(request) + await mycam.close() + -if __name__ == '__main__': +if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(media_profile_configuration()) diff --git a/onvif/cli.py b/onvif/cli.py deleted file mode 100644 index d66f47c..0000000 --- a/onvif/cli.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/python -"""ONVIF Client Command Line Interface""" -from __future__ import print_function, division -import re -from cmd import Cmd -from ast import literal_eval -from argparse import ArgumentParser, REMAINDER - -from zeep.exceptions import LookupError as MethodNotFound -from zeep.xsd import String as Text -from onvif import ONVIFCamera, ONVIFService, ONVIFError -from onvif.definition import SERVICES -import os.path - -SUPPORTED_SERVICES = SERVICES.keys() - - -class ThrowingArgumentParser(ArgumentParser): - """Exception throwing argument parser.""" - - def error(self, message): - usage = self.format_usage() - raise ValueError("%s\n%s" % (message, usage)) - - -def success(message): - """Print success message.""" - print("True: " + str(message)) - - -def error(message): - """Print error message.""" - print("False: " + str(message)) - - -class ONVIFCLI(Cmd): - """ONVIF CLI class.""" - - prompt = "ONVIF >>> " - client = None - cmd_parser = None - - def setup(self, args): - """ `args`: Instance of `argparse.ArgumentParser` """ - # Create onvif camera client - self.client = ONVIFCamera( - args.host, - args.port, - args.user, - args.password, - args.wsdl, - encrypt=args.encrypt, - ) - - # Create cmd argument parser - self.create_cmd_parser() - - def create_cmd_parser(self): - """Create parser to parse CMD, `params` is optional.""" - cmd_parser = ThrowingArgumentParser( - prog="ONVIF CMD", usage="CMD service operation [params]" - ) - cmd_parser.add_argument("service") - cmd_parser.add_argument("operation") - cmd_parser.add_argument("params", default="{}", nargs=REMAINDER) - self.cmd_parser = cmd_parser - - def do_cmd(self, line): - """Usage: CMD service operation [parameters]""" - try: - args = self.cmd_parser.parse_args(line.split()) - except ValueError as err: - return error(err) - - # Check if args.service is valid - if args.service not in SUPPORTED_SERVICES: - return error("No Service: " + args.service) - - args.params = "".join(args.params) - # params is optional - if not args.params.strip(): - args.params = "{}" - - # params must be a dictionary format string - match = re.match(r"^.*?(\{.*\}).*$", args.params) - if not match: - return error("Invalid params") - - try: - args.params = dict(literal_eval(match.group(1))) - except ValueError as err: - return error("Invalid params") - - try: - # Get ONVIF service - service = self.client.get_service(args.service) - # Actually execute the command and get the response - response = getattr(service, args.operation)(args.params) - except MethodNotFound as err: - return error("No Operation: %s" % args.operation) - except Exception as err: - return error(err) - - if isinstance(response, (Text, bool)): - return success(response) - # Try to convert instance to dictionary - try: - success(ONVIFService.to_dict(response)) - except ONVIFError: - error({}) - - # pylint: disable=no-self-use - def complete_cmd(self, text, line, begidx, endidx): - """Complete command.""" - if not text: - completions = SUPPORTED_SERVICES[:] - else: - completions = [key for key in SUPPORTED_SERVICES if key.startswith(text)] - return completions - - def emptyline(self): - """Empty line.""" - return "" - - # pylint: disable=no-self-use,invalid-name - def do_EOF(self, line): - """End of file.""" - return True - - -def create_parser(): - """Create parser.""" - parser = ThrowingArgumentParser(description=__doc__) - # Dealwith dependency for service, operation and params - parser.add_argument( - "service", nargs="?", help="Service defined by ONVIF WSDL document" - ) - parser.add_argument( - "operation", - nargs="?", - default="", - help="Operation to be execute defined" " by ONVIF WSDL document", - ) - parser.add_argument( - "params", - default="", - nargs="?", - help="JSON format params passed to the operation." - 'E.g., "{"Name": "NewHostName"}"', - ) - parser.add_argument( - "--host", - required=True, - help="ONVIF camera host, e.g. 192.168.2.123, " "www.example.com", - ) - parser.add_argument( - "--port", default=80, type=int, help="Port number for camera, default: 80" - ) - parser.add_argument( - "-u", "--user", required=True, help="Username for authentication" - ) - parser.add_argument( - "-a", "--password", required=True, help="Password for authentication" - ) - parser.add_argument( - "-w", - "--wsdl", - default=os.path.join(os.path.dirname(os.path.dirname(__file__)), "wsdl"), - help="directory to store ONVIF WSDL documents", - ) - parser.add_argument( - "-e", "--encrypt", default="False", help="Encrypt password or not" - ) - parser.add_argument( - "-v", "--verbose", action="store_true", help="increase output verbosity" - ) - parser.add_argument( - "--cache-location", - dest="cache_location", - default="/tmp/onvif/", - help="location to cache suds objects, default to /tmp/onvif/", - ) - parser.add_argument( - "--cache-duration", - dest="cache_duration", - help="how long will the cache be exist", - ) - - return parser - - -def main(): - """Main entrypoint.""" - # Create argument parser - parser = create_parser() - try: - args = parser.parse_args() - except ValueError as err: - print(str(err)) - return - # Also need parse configuration file. - - # Interactive command loop - cli = ONVIFCLI(stdin=input) - cli.setup(args) - if args.service: - cmd = " ".join(["cmd", args.service, args.operation, args.params]) - cli.onecmd(cmd) - # Execute command specified and exit - else: - cli.cmdloop() - - -if __name__ == "__main__": - main() diff --git a/onvif/client.py b/onvif/client.py index 2a7e69a..4a98c82 100644 --- a/onvif/client.py +++ b/onvif/client.py @@ -4,12 +4,13 @@ import logging import os.path -from aiohttp import ClientSession -from zeep.asyncio import AsyncTransport +from httpx import AsyncClient from zeep.cache import SqliteCache -from zeep.client import CachingClient, Client, Settings +from zeep.client import AsyncClient as BaseZeepAsyncClient, Settings from zeep.exceptions import Fault import zeep.helpers +from zeep.proxy import AsyncServiceProxy +from zeep.transports import AsyncTransport from zeep.wsse.username import UsernameToken from onvif.definition import SERVICES @@ -27,7 +28,16 @@ def wrapped(*args, **kwargs): try: return func(*args, **kwargs) except Exception as err: - # print('Ouuups: err =', err, ', func =', func, ', args =', args, ', kwargs =', kwargs) + print( + "Ouuups: err =", + err, + ", func =", + func, + ", args =", + args, + ", kwargs =", + kwargs, + ) raise ONVIFError(err) return wrapped @@ -57,6 +67,24 @@ def apply(self, envelope, headers): return result +class ZeepAsyncClient(BaseZeepAsyncClient): + """Overwrite create_service method to be async.""" + + def create_service(self, binding_name, address): + """Create a new ServiceProxy for the given binding name and address. + :param binding_name: The QName of the binding + :param address: The address of the endpoint + """ + try: + binding = self.wsdl.bindings[binding_name] + except KeyError: + raise ValueError( + "No binding found with the given QName. Available bindings " + "are: %s" % (", ".join(self.wsdl.bindings.keys())) + ) + return AsyncServiceProxy(self, binding, address=address) + + class ONVIFService: """ Python Implemention for ONVIF Service. @@ -114,17 +142,16 @@ def __init__( # Create soap client if not zeep_client: if not self.transport: - session = ClientSession() + client = AsyncClient() self.transport = ( - AsyncTransport(None, session=session) + AsyncTransport(client=client) if no_cache - else AsyncTransport(None, session=session, cache=SqliteCache()) + else AsyncTransport(client=client, cache=SqliteCache()) ) - ClientType = Client if no_cache else CachingClient settings = Settings() settings.strict = False settings.xml_huge_tree = True - self.zeep_client = ClientType( + self.zeep_client = ZeepAsyncClient( wsdl=url, wsse=wsse, transport=self.transport, settings=settings ) else: @@ -147,8 +174,8 @@ def __init__( self.create_type = lambda x: self.zeep_client.get_element(active_ns + ":" + x)() async def close(self): - """Close the transport session.""" - await self.transport.session.close() + """Close the transport.""" + await self.transport.aclose() @staticmethod @safe_func diff --git a/onvif/version.txt b/onvif/version.txt index a918a2a..3eefcb9 100644 --- a/onvif/version.txt +++ b/onvif/version.txt @@ -1 +1 @@ -0.6.0 +1.0.0 diff --git a/tests/test.py b/tests/test.py index 095320c..3d74be8 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,115 +1,114 @@ -#!/usr/bin/python -#-*-coding=utf-8 -from __future__ import print_function, division -import unittest - -from onvif import ONVIFCamera, ONVIFError - -CAM_HOST = '172.20.9.84' -CAM_PORT = 80 -CAM_USER = 'root' -CAM_PASS = 'password' - -DEBUG = False - -def log(ret): - if DEBUG: - print(ret) - -class TestDevice(unittest.TestCase): - - # Class level cam. Run this test more efficiently.. - cam = ONVIFCamera(CAM_HOST, CAM_PORT, CAM_USER, CAM_PASS) - - # ***************** Test Capabilities *************************** - def test_GetWsdlUrl(self): - ret = self.cam.devicemgmt.GetWsdlUrl() - - def test_GetServices(self): - ''' - Returns a cllection of the devices - services and possibly their available capabilities - ''' - params = {'IncludeCapability': True } - ret = self.cam.devicemgmt.GetServices(params) - params = self.cam.devicemgmt.create_type('GetServices') - params.IncludeCapability=False - ret = self.cam.devicemgmt.GetServices(params) - - def test_GetServiceCapabilities(self): - '''Returns the capabilities of the devce service.''' - ret = self.cam.devicemgmt.GetServiceCapabilities() - ret.Network.IPFilter - - def test_GetCapabilities(self): - ''' - Probides a backward compatible interface for the base capabilities. - ''' - categorys = ['PTZ', 'Media', 'Imaging', - 'Device', 'Analytics', 'Events'] - ret = self.cam.devicemgmt.GetCapabilities() - for category in categorys: - ret = self.cam.devicemgmt.GetCapabilities({'Category': category}) - - with self.assertRaises(ONVIFError): - self.cam.devicemgmt.GetCapabilities({'Category': 'unknown'}) - - # *************** Test Network ********************************* - def test_GetHostname(self): - ''' Get the hostname from a device ''' - self.cam.devicemgmt.GetHostname() - - def test_SetHostname(self): - ''' - Set the hostname on a device - A device shall accept strings formated according to - RFC 1123 section 2.1 or alternatively to RFC 952, - other string shall be considered as invalid strings - ''' - pre_host_name = self.cam.devicemgmt.GetHostname() - - self.cam.devicemgmt.SetHostname({'Name':'testHostName'}) - self.assertEqual(self.cam.devicemgmt.GetHostname().Name, 'testHostName') - - self.cam.devicemgmt.SetHostname({'Name':pre_host_name.Name}) - - def test_SetHostnameFromDHCP(self): - ''' Controls whether the hostname shall be retrieved from DHCP ''' - ret = self.cam.devicemgmt.SetHostnameFromDHCP(dict(FromDHCP=False)) - self.assertTrue(isinstance(ret, bool)) - - def test_GetDNS(self): - ''' Gets the DNS setting from a device ''' - ret = self.cam.devicemgmt.GetDNS() - self.assertTrue(hasattr(ret, 'FromDHCP')) - if not ret.FromDHCP and len(ret.DNSManual) > 0: - log(ret.DNSManual[0].Type) - log(ret.DNSManual[0].IPv4Address) - - def test_SetDNS(self): - ''' Set the DNS settings on a device ''' - ret = self.cam.devicemgmt.SetDNS(dict(FromDHCP=False)) - - def test_GetNTP(self): - ''' Get the NTP settings from a device ''' - ret = self.cam.devicemgmt.GetNTP() - if ret.FromDHCP == False: - self.assertTrue(hasattr(ret, 'NTPManual')) - log(ret.NTPManual) - - def test_SetNTP(self): - '''Set the NTP setting''' - ret = self.cam.devicemgmt.SetNTP(dict(FromDHCP=False)) - - def test_GetDynamicDNS(self): - '''Get the dynamic DNS setting''' - ret = self.cam.devicemgmt.GetDynamicDNS() - log(ret) - - def test_SetDynamicDNS(self): - ''' Set the dynamic DNS settings on a device ''' - ret = self.cam.devicemgmt.GetDynamicDNS() - ret = self.cam.devicemgmt.SetDynamicDNS({'Type': 'NoUpdate', 'Name':None, 'TTL':None}) - -if __name__ == '__main__': - unittest.main() +# #!/usr/bin/python +# from __future__ import print_function, division +# import unittest + +# from onvif import ONVIFCamera, ONVIFError + +# CAM_HOST = '172.20.9.84' +# CAM_PORT = 80 +# CAM_USER = 'root' +# CAM_PASS = 'password' + +# DEBUG = False + +# def log(ret): +# if DEBUG: +# print(ret) + +# class TestDevice(unittest.TestCase): + +# # Class level cam. Run this test more efficiently.. +# cam = ONVIFCamera(CAM_HOST, CAM_PORT, CAM_USER, CAM_PASS) + +# # ***************** Test Capabilities *************************** +# def test_GetWsdlUrl(self): +# ret = self.cam.devicemgmt.GetWsdlUrl() + +# def test_GetServices(self): +# ''' +# Returns a cllection of the devices +# services and possibly their available capabilities +# ''' +# params = {'IncludeCapability': True } +# ret = self.cam.devicemgmt.GetServices(params) +# params = self.cam.devicemgmt.create_type('GetServices') +# params.IncludeCapability=False +# ret = self.cam.devicemgmt.GetServices(params) + +# def test_GetServiceCapabilities(self): +# '''Returns the capabilities of the devce service.''' +# ret = self.cam.devicemgmt.GetServiceCapabilities() +# ret.Network.IPFilter + +# def test_GetCapabilities(self): +# ''' +# Probides a backward compatible interface for the base capabilities. +# ''' +# categorys = ['PTZ', 'Media', 'Imaging', +# 'Device', 'Analytics', 'Events'] +# ret = self.cam.devicemgmt.GetCapabilities() +# for category in categorys: +# ret = self.cam.devicemgmt.GetCapabilities({'Category': category}) + +# with self.assertRaises(ONVIFError): +# self.cam.devicemgmt.GetCapabilities({'Category': 'unknown'}) + +# # *************** Test Network ********************************* +# def test_GetHostname(self): +# ''' Get the hostname from a device ''' +# self.cam.devicemgmt.GetHostname() + +# def test_SetHostname(self): +# ''' +# Set the hostname on a device +# A device shall accept strings formated according to +# RFC 1123 section 2.1 or alternatively to RFC 952, +# other string shall be considered as invalid strings +# ''' +# pre_host_name = self.cam.devicemgmt.GetHostname() + +# self.cam.devicemgmt.SetHostname({'Name':'testHostName'}) +# self.assertEqual(self.cam.devicemgmt.GetHostname().Name, 'testHostName') + +# self.cam.devicemgmt.SetHostname({'Name':pre_host_name.Name}) + +# def test_SetHostnameFromDHCP(self): +# ''' Controls whether the hostname shall be retrieved from DHCP ''' +# ret = self.cam.devicemgmt.SetHostnameFromDHCP(dict(FromDHCP=False)) +# self.assertTrue(isinstance(ret, bool)) + +# def test_GetDNS(self): +# ''' Gets the DNS setting from a device ''' +# ret = self.cam.devicemgmt.GetDNS() +# self.assertTrue(hasattr(ret, 'FromDHCP')) +# if not ret.FromDHCP and len(ret.DNSManual) > 0: +# log(ret.DNSManual[0].Type) +# log(ret.DNSManual[0].IPv4Address) + +# def test_SetDNS(self): +# ''' Set the DNS settings on a device ''' +# ret = self.cam.devicemgmt.SetDNS(dict(FromDHCP=False)) + +# def test_GetNTP(self): +# ''' Get the NTP settings from a device ''' +# ret = self.cam.devicemgmt.GetNTP() +# if ret.FromDHCP == False: +# self.assertTrue(hasattr(ret, 'NTPManual')) +# log(ret.NTPManual) + +# def test_SetNTP(self): +# '''Set the NTP setting''' +# ret = self.cam.devicemgmt.SetNTP(dict(FromDHCP=False)) + +# def test_GetDynamicDNS(self): +# '''Get the dynamic DNS setting''' +# ret = self.cam.devicemgmt.GetDynamicDNS() +# log(ret) + +# def test_SetDynamicDNS(self): +# ''' Set the dynamic DNS settings on a device ''' +# ret = self.cam.devicemgmt.GetDynamicDNS() +# ret = self.cam.devicemgmt.SetDynamicDNS({'Type': 'NoUpdate', 'Name':None, 'TTL':None}) + +# if __name__ == '__main__': +# unittest.main() From 1be30cc2dd9ddfebce0c6d36ca9f535e86ccb31b Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sat, 17 Oct 2020 18:21:13 -0400 Subject: [PATCH 3/6] add get_snapshot --- onvif/__init__.py | 8 ++++---- onvif/client.py | 49 +++++++++++++++++++++++++++++++++++++++++++-- onvif/exceptions.py | 14 +++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/onvif/__init__.py b/onvif/__init__.py index 5e10e24..b229f7d 100644 --- a/onvif/__init__.py +++ b/onvif/__init__.py @@ -1,13 +1,13 @@ """Initialize onvif.""" import zeep -from onvif.client import ONVIFService, ONVIFCamera, SERVICES +from onvif.client import SERVICES, ONVIFCamera, ONVIFService from onvif.exceptions import ( - ONVIFError, - ERR_ONVIF_UNKNOWN, + ERR_ONVIF_BUILD, ERR_ONVIF_PROTOCOL, + ERR_ONVIF_UNKNOWN, ERR_ONVIF_WSDL, - ERR_ONVIF_BUILD, + ONVIFError, ) diff --git a/onvif/client.py b/onvif/client.py index 4a98c82..e8a6dfb 100644 --- a/onvif/client.py +++ b/onvif/client.py @@ -4,7 +4,8 @@ import logging import os.path -from httpx import AsyncClient +import httpx +from httpx import AsyncClient, BasicAuth, DigestAuth from zeep.cache import SqliteCache from zeep.client import AsyncClient as BaseZeepAsyncClient, Settings from zeep.exceptions import Fault @@ -14,7 +15,7 @@ from zeep.wsse.username import UsernameToken from onvif.definition import SERVICES -from onvif.exceptions import ONVIFError +from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError logger = logging.getLogger("onvif") logging.basicConfig(level=logging.INFO) @@ -270,6 +271,9 @@ def __init__( self.to_dict = ONVIFService.to_dict + self._client = AsyncClient() + self._snapshot_uris = {} + async def update_xaddrs(self): """Update xaddrs for services.""" self.dt_diff = None @@ -314,9 +318,50 @@ async def create_pullpoint_subscription(self): async def close(self): """Close all transports.""" + await self._client.aclose() for service in self.services.values(): await service.close() + async def get_snapshot_uri(self, profile_token): + """Get the snapshot uri for a given profile.""" + uri = self._snapshot_uris.get(profile_token) + if uri is None: + media_service = self.create_media_service() + req = media_service.create_type("GetSnapshotUri") + req.ProfileToken = profile_token + result = await media_service.GetSnapshotUri(req) + uri = result.Uri + self._snapshot_uris[profile_token] = uri + return uri + + async def get_snapshot(self, profile_token, basic_auth=False): + """Get a snapshot image from the camera.""" + uri = await self.get_snapshot_uri(profile_token) + if uri is None: + return None + + auth = None + if self.user and self.passwd: + if basic_auth: + auth = BasicAuth(self.user, self.passwd) + else: + auth = DigestAuth(self.user, self.passwd) + + try: + response = await self._client.get(uri, auth=auth) + except httpx.TimeoutException as error: + raise ONVIFTimeoutError(error) from error + except httpx.RequestError as error: + raise ONVIFError(error) from error + + if response.status_code == 401: + raise ONVIFAuthError(f"Failed to authenticate to {uri}") + + if response.status_code < 300: + return response.content + + return None + def get_definition(self, name, port_type=None): """Returns xaddr and wsdl of specified service""" # Check if the service is supported diff --git a/onvif/exceptions.py b/onvif/exceptions.py index b18db9c..dddce85 100644 --- a/onvif/exceptions.py +++ b/onvif/exceptions.py @@ -22,3 +22,17 @@ def __init__(self, err): def __str__(self): return self.reason + + +class ONVIFTimeoutError(ONVIFError): + """ONVIF Timeout Exception class.""" + + def __init__(self, err): + super().__init__(err) + + +class ONVIFAuthError(ONVIFError): + """ONVIF Authentication Exception class.""" + + def __init__(self, err): + super().__init__(err) From 43898730459b6cc04499eead55a12581c35d98fc Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sat, 17 Oct 2020 19:09:03 -0400 Subject: [PATCH 4/6] default timeout to 90 seconds --- onvif/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onvif/client.py b/onvif/client.py index e8a6dfb..4255464 100644 --- a/onvif/client.py +++ b/onvif/client.py @@ -143,7 +143,7 @@ def __init__( # Create soap client if not zeep_client: if not self.transport: - client = AsyncClient() + client = AsyncClient(timeout=90) self.transport = ( AsyncTransport(client=client) if no_cache From 93fefc7dd0836bae60219636a6a8d525c01f27e0 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sat, 17 Oct 2020 21:10:58 -0400 Subject: [PATCH 5/6] share httpx client --- onvif/client.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/onvif/client.py b/onvif/client.py index 4255464..ba9d45b 100644 --- a/onvif/client.py +++ b/onvif/client.py @@ -7,7 +7,7 @@ import httpx from httpx import AsyncClient, BasicAuth, DigestAuth from zeep.cache import SqliteCache -from zeep.client import AsyncClient as BaseZeepAsyncClient, Settings +from zeep.client import AsyncClient as BaseZeepAsyncClient, Client, Settings from zeep.exceptions import Fault import zeep.helpers from zeep.proxy import AsyncServiceProxy @@ -130,6 +130,7 @@ def __init__( dt_diff=None, binding_name="", transport=None, + client=None, ): if not os.path.isfile(url): raise ONVIFError("%s doesn`t exist!" % url) @@ -143,7 +144,8 @@ def __init__( # Create soap client if not zeep_client: if not self.transport: - client = AsyncClient(timeout=90) + if not client: + client = AsyncClient(timeout=90) self.transport = ( AsyncTransport(client=client) if no_cache @@ -251,6 +253,7 @@ def __init__( no_cache=False, adjust_time=False, transport=None, + client=None, ): os.environ.pop("http_proxy", None) os.environ.pop("https_proxy", None) @@ -263,6 +266,7 @@ def __init__( self.no_cache = no_cache self.adjust_time = adjust_time self.transport = transport + self.client = client or AsyncClient(timeout=90) self.dt_diff = None self.xaddrs = {} @@ -271,7 +275,6 @@ def __init__( self.to_dict = ONVIFService.to_dict - self._client = AsyncClient() self._snapshot_uris = {} async def update_xaddrs(self): @@ -318,7 +321,7 @@ async def create_pullpoint_subscription(self): async def close(self): """Close all transports.""" - await self._client.aclose() + await self.client.aclose() for service in self.services.values(): await service.close() @@ -348,7 +351,7 @@ async def get_snapshot(self, profile_token, basic_auth=False): auth = DigestAuth(self.user, self.passwd) try: - response = await self._client.get(uri, auth=auth) + response = await self.client.get(uri, auth=auth) except httpx.TimeoutException as error: raise ONVIFTimeoutError(error) from error except httpx.RequestError as error: @@ -418,6 +421,7 @@ def create_onvif_service(self, name, port_type=None): dt_diff=self.dt_diff, binding_name=binding_name, transport=self.transport, + client=self.client, ) self.services[binding_name] = service From 660dfe6b810d6629ef89fc2397d4651902e93dea Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sun, 18 Oct 2020 00:35:13 -0400 Subject: [PATCH 6/6] do not share clients across services. it causes all sorts of issues... --- onvif/client.py | 62 +++++++++++++++---------------------------------- 1 file changed, 19 insertions(+), 43 deletions(-) diff --git a/onvif/client.py b/onvif/client.py index ba9d45b..d99681c 100644 --- a/onvif/client.py +++ b/onvif/client.py @@ -1,5 +1,4 @@ """ONVIF Client.""" - import datetime as dt import logging import os.path @@ -29,16 +28,6 @@ def wrapped(*args, **kwargs): try: return func(*args, **kwargs) except Exception as err: - print( - "Ouuups: err =", - err, - ", func =", - func, - ", args =", - args, - ", kwargs =", - kwargs, - ) raise ONVIFError(err) return wrapped @@ -125,40 +114,31 @@ def __init__( passwd, url, encrypt=True, - zeep_client=None, no_cache=False, dt_diff=None, binding_name="", - transport=None, - client=None, ): if not os.path.isfile(url): raise ONVIFError("%s doesn`t exist!" % url) self.url = url self.xaddr = xaddr - self.transport = transport wsse = UsernameDigestTokenDtDiff( user, passwd, dt_diff=dt_diff, use_digest=encrypt ) # Create soap client - if not zeep_client: - if not self.transport: - if not client: - client = AsyncClient(timeout=90) - self.transport = ( - AsyncTransport(client=client) - if no_cache - else AsyncTransport(client=client, cache=SqliteCache()) - ) - settings = Settings() - settings.strict = False - settings.xml_huge_tree = True - self.zeep_client = ZeepAsyncClient( - wsdl=url, wsse=wsse, transport=self.transport, settings=settings - ) - else: - self.zeep_client = zeep_client + client = AsyncClient(timeout=90) + self.transport = ( + AsyncTransport(client=client) + if no_cache + else AsyncTransport(client=client, cache=SqliteCache()) + ) + settings = Settings() + settings.strict = False + settings.xml_huge_tree = True + self.zeep_client = ZeepAsyncClient( + wsdl=url, wsse=wsse, transport=self.transport, settings=settings + ) self.ws_client = self.zeep_client.create_service(binding_name, self.xaddr) # Set soap header for authentication @@ -252,8 +232,6 @@ def __init__( encrypt=True, no_cache=False, adjust_time=False, - transport=None, - client=None, ): os.environ.pop("http_proxy", None) os.environ.pop("https_proxy", None) @@ -265,8 +243,6 @@ def __init__( self.encrypt = encrypt self.no_cache = no_cache self.adjust_time = adjust_time - self.transport = transport - self.client = client or AsyncClient(timeout=90) self.dt_diff = None self.xaddrs = {} @@ -276,6 +252,7 @@ def __init__( self.to_dict = ONVIFService.to_dict self._snapshot_uris = {} + self._snapshot_client = AsyncClient() async def update_xaddrs(self): """Update xaddrs for services.""" @@ -321,7 +298,7 @@ async def create_pullpoint_subscription(self): async def close(self): """Close all transports.""" - await self.client.aclose() + await self._snapshot_client.aclose() for service in self.services.values(): await service.close() @@ -351,7 +328,7 @@ async def get_snapshot(self, profile_token, basic_auth=False): auth = DigestAuth(self.user, self.passwd) try: - response = await self.client.get(uri, auth=auth) + response = await self._snapshot_client.get(uri, auth=auth) except httpx.TimeoutException as error: raise ONVIFTimeoutError(error) from error except httpx.RequestError as error: @@ -407,8 +384,9 @@ def create_onvif_service(self, name, port_type=None): # Don't re-create bindings if the xaddr remains the same. # The xaddr can change when a new PullPointSubscription is created. - binding = self.services.get(binding_name) - if binding and binding.xaddr == xaddr: + binding_key = f"{binding_name}{xaddr}" + binding = self.services.get(binding_key) + if binding: return binding service = ONVIFService( @@ -420,11 +398,9 @@ def create_onvif_service(self, name, port_type=None): no_cache=self.no_cache, dt_diff=self.dt_diff, binding_name=binding_name, - transport=self.transport, - client=self.client, ) - self.services[binding_name] = service + self.services[binding_key] = service return service