├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.txt ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── bandit.yaml ├── examples ├── events.py ├── rotate_image.py └── streaming.py ├── onvif ├── __init__.py ├── client.py ├── const.py ├── definition.py ├── exceptions.py ├── managers.py ├── settings.py ├── transport.py ├── types.py ├── util.py ├── version.txt ├── wrappers.py ├── wsa.py ├── wsdl │ ├── __init__.py │ ├── accesscontrol.wsdl │ ├── actionengine.wsdl │ ├── addressing │ ├── advancedsecurity.wsdl │ ├── analytics.wsdl │ ├── analyticsdevice.wsdl │ ├── b-2.xsd │ ├── bf-2.xsd │ ├── bw-2.wsdl │ ├── deviceio.wsdl │ ├── devicemgmt.wsdl │ ├── display.wsdl │ ├── doorcontrol.wsdl │ ├── envelope │ ├── events.wsdl │ ├── imaging.wsdl │ ├── include │ ├── media.wsdl │ ├── onvif.xsd │ ├── ptz.wsdl │ ├── r-2.xsd │ ├── receiver.wsdl │ ├── recording.wsdl │ ├── remotediscovery.wsdl │ ├── replay.wsdl │ ├── rw-2.wsdl │ ├── search.wsdl │ ├── t-1.xsd │ ├── types.xsd │ ├── ws-addr.xsd │ ├── ws-discovery.xsd │ ├── xml.xsd │ └── xmlmime └── zeep_aiohttp.py ├── pylintrc ├── pyproject.toml ├── requirements.txt ├── requirements_dev.txt ├── setup.cfg ├── setup.py └── tests ├── test.py ├── test_snapshot.py ├── test_types.py ├── test_util.py └── test_zeep_transport.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | commit-message: 13 | prefix: "chore(deps-ci): " 14 | groups: 15 | github-actions: 16 | patterns: 17 | - "*" 18 | - package-ecosystem: "pip" # See documentation for possible values 19 | directory: "/" # Location of package manifests 20 | schedule: 21 | interval: "weekly" 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [async] 7 | 8 | jobs: 9 | coverage: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12", "3.13"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | cache: "pip" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements_dev.txt . 27 | - name: Pre-commit 28 | uses: pre-commit/action@v3.0.1 29 | - name: Tests 30 | run: python -m pytest --cov=onvif --cov-report=term-missing --cov-report=xml tests 31 | - name: Upload coverage to Codecov 32 | uses: codecov/codecov-action@v5.4.2 33 | with: 34 | token: ${{ secrets.CODECOV_TOKEN }} # required 35 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | name: Upload Python Package 4 | 5 | on: 6 | release: 7 | types: [created] 8 | 9 | jobs: 10 | build: 11 | name: Build distribution 📦 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Install pypa/build 21 | run: >- 22 | python3 -m 23 | pip install 24 | build 25 | --user 26 | - name: Build a binary wheel and a source tarball 27 | run: python3 -m build 28 | - name: Store the distribution packages 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: python-package-distributions 32 | path: dist/ 33 | 34 | deploy: 35 | permissions: 36 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 37 | runs-on: ubuntu-latest 38 | needs: 39 | - build 40 | name: >- 41 | Publish Python 🐍 distribution 📦 to PyPI 42 | environment: 43 | name: pypi 44 | url: https://pypi.org/p/onvif-zeep-async 45 | 46 | steps: 47 | - name: Download all the dists 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: python-package-distributions 51 | path: dist/ 52 | - name: Publish package distributions to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | venv/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | 57 | .idea 58 | .venv 59 | .vscode/ 60 | Pipfile 61 | Pipfile.lock 62 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: "CHANGELOG.md|.copier-answers.yml|.all-contributorsrc" 4 | default_stages: [pre-commit] 5 | 6 | ci: 7 | autofix_commit_msg: "chore(pre-commit.ci): auto fixes" 8 | autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" 9 | 10 | repos: 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v5.0.0 13 | hooks: 14 | - id: debug-statements 15 | - id: check-builtin-literals 16 | - id: check-case-conflict 17 | - id: check-docstring-first 18 | - id: check-toml 19 | - id: check-xml 20 | - id: detect-private-key 21 | - id: end-of-file-fixer 22 | - id: trailing-whitespace 23 | - repo: https://github.com/pre-commit/mirrors-prettier 24 | rev: v4.0.0-alpha.8 25 | hooks: 26 | - id: prettier 27 | args: ["--tab-width", "2"] 28 | - repo: https://github.com/astral-sh/ruff-pre-commit 29 | rev: v0.11.12 30 | hooks: 31 | - id: ruff 32 | args: [--fix, --exit-non-zero-on-fix] 33 | - id: ruff-format 34 | - repo: https://github.com/asottile/pyupgrade 35 | rev: v3.20.0 36 | hooks: 37 | - id: pyupgrade 38 | args: [--py310-plus] 39 | # - repo: https://github.com/pre-commit/mirrors-mypy 40 | # rev: v1.11.2 41 | # hooks: 42 | # - id: mypy 43 | # additional_dependencies: [] 44 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openvideolibs/python-onvif-zeep-async/9bc26e6cb44b4446acc1288bfed915aa3ba2ff8b/CHANGES.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Quatanium Co., Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include onvif/version.txt 2 | include CHANGES.txt 3 | include LICENSE 4 | include README.* 5 | include onvif/wsdl/* 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 2 | 3 | clean-build: ## remove build artifacts 4 | rm -fr build/ 5 | rm -fr dist/ 6 | rm -fr .eggs/ 7 | find . -name '*.egg-info' -exec rm -fr {} + 8 | find . -name '*.egg' -exec rm -fr {} + 9 | 10 | clean-pyc: ## remove Python file artifacts 11 | find . -name '*.pyc' -exec rm -f {} + 12 | find . -name '*.pyo' -exec rm -f {} + 13 | find . -name '*~' -exec rm -f {} + 14 | find . -name '__pycache__' -exec rm -fr {} + 15 | 16 | clean-test: ## remove test and coverage artifacts 17 | rm -fr .tox/ 18 | rm -f .coverage 19 | rm -fr htmlcov/ 20 | 21 | lint: ## check style with flake8 22 | flake8 onvif tests 23 | pylint onvif 24 | 25 | test: ## run tests quickly with the default Python 26 | pytest --cov=onvif --cov-report html tests/ 27 | 28 | release: ## package and upload a release 29 | python3 -m twine upload dist/* 30 | 31 | dist: clean ## builds source and wheel package 32 | python3 setup.py sdist bdist_wheel 33 | ls -l dist 34 | 35 | install: clean ## install the package to the active Python's site-packages 36 | pip3 install -r requirements.txt 37 | pre-commit install 38 | pip3 install -e . 39 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-onvif-zeep-async 2 | ======================= 3 | 4 | ONVIF Client Implementation in Python 3 5 | 6 | Dependencies 7 | ------------ 8 | `zeep[async] `_ >= 4.1.0, < 5.0.0 9 | `httpx `_ >= 0.19.0, < 1.0.0 10 | 11 | Install python-onvif-zeep-async 12 | ------------------------------- 13 | **From Source** 14 | 15 | You should clone this repository and run setup.py:: 16 | 17 | cd python-onvif-zeep-async && python setup.py install 18 | 19 | Alternatively, you can run:: 20 | 21 | pip install --upgrade onvif-zeep-async 22 | 23 | 24 | Getting Started 25 | --------------- 26 | 27 | Initialize an ONVIFCamera instance 28 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 29 | 30 | :: 31 | 32 | from onvif import ONVIFCamera 33 | mycam = ONVIFCamera('192.168.0.2', 80, 'user', 'passwd', '/etc/onvif/wsdl/') 34 | await mycam.update_xaddrs() 35 | 36 | Now, an ONVIFCamera instance is available. By default, a devicemgmt service is also available if everything is OK. 37 | 38 | So, all operations defined in the WSDL document:: 39 | 40 | /etc/onvif/wsdl/devicemgmt.wsdl 41 | 42 | are available. 43 | 44 | Get information from your camera 45 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 46 | :: 47 | 48 | # Get Hostname 49 | resp = await mycam.devicemgmt.GetHostname() 50 | print 'My camera`s hostname: ' + str(resp.Name) 51 | 52 | # Get system date and time 53 | dt = await mycam.devicemgmt.GetSystemDateAndTime() 54 | tz = dt.TimeZone 55 | year = dt.UTCDateTime.Date.Year 56 | hour = dt.UTCDateTime.Time.Hour 57 | 58 | Configure (Control) your camera 59 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 60 | 61 | To configure your camera, there are two ways to pass parameters to service methods. 62 | 63 | **Dict** 64 | 65 | This is the simpler way:: 66 | 67 | params = {'Name': 'NewHostName'} 68 | await device_service.SetHostname(params) 69 | 70 | **Type Instance** 71 | 72 | This is the recommended way. Type instance will raise an 73 | exception if you set an invalid (or non-existent) parameter. 74 | 75 | :: 76 | 77 | params = mycam.devicemgmt.create_type('SetHostname') 78 | params.Hostname = 'NewHostName' 79 | await mycam.devicemgmt.SetHostname(params) 80 | 81 | time_params = mycam.devicemgmt.create_type('SetSystemDateAndTime') 82 | time_params.DateTimeType = 'Manual' 83 | time_params.DaylightSavings = True 84 | time_params.TimeZone.TZ = 'CST-8:00:00' 85 | time_params.UTCDateTime.Date.Year = 2014 86 | time_params.UTCDateTime.Date.Month = 12 87 | time_params.UTCDateTime.Date.Day = 3 88 | time_params.UTCDateTime.Time.Hour = 9 89 | time_params.UTCDateTime.Time.Minute = 36 90 | time_params.UTCDateTime.Time.Second = 11 91 | await mycam.devicemgmt.SetSystemDateAndTime(time_params) 92 | 93 | Use other services 94 | ~~~~~~~~~~~~~~~~~~ 95 | ONVIF protocol has defined many services. 96 | You can find all the services and operations `here `_. 97 | ONVIFCamera has support methods to create new services:: 98 | 99 | # Create ptz service 100 | ptz_service = mycam.create_ptz_service() 101 | # Get ptz configuration 102 | await mycam.ptz.GetConfiguration() 103 | # Another way 104 | # await ptz_service.GetConfiguration() 105 | 106 | Or create an unofficial service:: 107 | 108 | xaddr = 'http://192.168.0.3:8888/onvif/yourservice' 109 | yourservice = mycam.create_onvif_service('service.wsdl', xaddr, 'yourservice') 110 | await yourservice.SomeOperation() 111 | # Another way 112 | # await mycam.yourservice.SomeOperation() 113 | 114 | References 115 | ---------- 116 | 117 | * `ONVIF Offical Website `_ 118 | 119 | * `Operations Index `_ 120 | 121 | * `ONVIF Develop Documents `_ 122 | 123 | * `Foscam Python Lib `_ 124 | -------------------------------------------------------------------------------- /bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /examples/events.py: -------------------------------------------------------------------------------- 1 | """Example to fetch pullpoint events.""" 2 | 3 | from aiohttp import web 4 | import argparse 5 | import asyncio 6 | import datetime as dt 7 | import logging 8 | import onvif 9 | import os.path 10 | import pprint 11 | import sys 12 | 13 | SUBSCRIPTION_TIME = dt.timedelta(minutes=1) 14 | WAIT_TIME = dt.timedelta(seconds=30) 15 | 16 | 17 | def subscription_lost(): 18 | print("subscription lost") 19 | 20 | 21 | async def post_handler(request): 22 | print(request) 23 | print(request.url) 24 | for k, v in request.headers.items(): 25 | print(f"{k}: {v}") 26 | body = await request.content.read() 27 | print(body) 28 | return web.Response() 29 | 30 | 31 | async def run(args): 32 | mycam = onvif.ONVIFCamera( 33 | args.host, 34 | args.port, 35 | args.username, 36 | args.password, 37 | wsdl_dir=f"{os.path.dirname(onvif.__file__)}/wsdl/", 38 | ) 39 | await mycam.update_xaddrs() 40 | 41 | capabilities = await mycam.get_capabilities() 42 | pprint.pprint(capabilities) 43 | 44 | if args.notification: 45 | app = web.Application() 46 | app.add_routes([web.post("/", post_handler)]) 47 | runner = web.AppRunner(app) 48 | await runner.setup() 49 | site = web.TCPSite(runner, args.notification_address, args.notification_port) 50 | await site.start() 51 | 52 | receive_url = f"http://{args.notification_address}:{args.notification_port}/" 53 | manager = await mycam.create_notification_manager( 54 | receive_url, 55 | SUBSCRIPTION_TIME, 56 | subscription_lost, 57 | ) 58 | await manager.set_synchronization_point() 59 | 60 | print(f"waiting for messages at {receive_url}...") 61 | await asyncio.sleep(WAIT_TIME.total_seconds()) 62 | 63 | await manager.shutdown() 64 | await runner.cleanup() 65 | else: 66 | manager = await mycam.create_pullpoint_manager( 67 | SUBSCRIPTION_TIME, subscription_lost 68 | ) 69 | await manager.set_synchronization_point() 70 | 71 | pullpoint = manager.get_service() 72 | print("waiting for messages...") 73 | messages = await pullpoint.PullMessages( 74 | { 75 | "MessageLimit": 100, 76 | "Timeout": WAIT_TIME, 77 | } 78 | ) 79 | print(messages) 80 | 81 | await manager.shutdown() 82 | 83 | await mycam.close() 84 | 85 | 86 | def main(): 87 | logging.getLogger("zeep").setLevel(logging.DEBUG) 88 | 89 | parser = argparse.ArgumentParser(prog="EventTester") 90 | parser.add_argument("--host", default="192.168.3.10") 91 | parser.add_argument("--port", type=int, default=80) 92 | parser.add_argument("--username", default="hass") 93 | parser.add_argument("--password", default="peek4boo") 94 | parser.add_argument("--notification", action=argparse.BooleanOptionalAction) 95 | parser.add_argument("--notification_address") 96 | parser.add_argument("--notification_port", type=int, default=8976) 97 | 98 | args = parser.parse_args(sys.argv[1:]) 99 | if args.notification and args.notification_address is None: 100 | parser.error("--notification requires --notification_address") 101 | 102 | loop = asyncio.get_event_loop() 103 | loop.run_until_complete(run(args)) 104 | 105 | 106 | if __name__ == "__main__": 107 | main() 108 | -------------------------------------------------------------------------------- /examples/rotate_image.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from onvif import ONVIFCamera 4 | 5 | 6 | async def rotate_image_180(): 7 | """Rotate the image""" 8 | 9 | # Create the media service 10 | mycam = ONVIFCamera("192.168.0.112", 80, "admin", "12345") 11 | await mycam.update_xaddrs() 12 | media_service = mycam.create_media_service() 13 | 14 | profiles = await media_service.GetProfiles() 15 | 16 | # Use the first profile and Profiles have at least one 17 | token = profiles[0].token # noqa: F841 18 | 19 | # Get all video source configurations 20 | configurations_list = await media_service.GetVideoSourceConfigurations() 21 | 22 | # Use the first profile and Profiles have at least one 23 | video_source_configuration = configurations_list[0] 24 | 25 | # Enable rotate 26 | video_source_configuration.Extension[0].Rotate[0].Mode[0] = "OFF" 27 | 28 | # Create request type instance 29 | request = media_service.create_type("SetVideoSourceConfiguration") 30 | request.Configuration = video_source_configuration 31 | 32 | # ForcePersistence is obsolete and should always be assumed to be True 33 | request.ForcePersistence = True 34 | 35 | # Set the video source configuration 36 | await media_service.SetVideoSourceConfiguration(request) 37 | await mycam.close() 38 | 39 | 40 | if __name__ == "__main__": 41 | loop = asyncio.get_event_loop() 42 | loop.run_until_complete(rotate_image_180()) 43 | -------------------------------------------------------------------------------- /examples/streaming.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from onvif import ONVIFCamera 4 | 5 | 6 | async def media_profile_configuration(): 7 | """ 8 | A media profile consists of configuration entities such as video/audio 9 | source configuration, video/audio encoder configuration, 10 | or PTZ configuration. This use case describes how to change one 11 | configuration entity which has been already added to the media profile. 12 | """ 13 | 14 | # Create the media service 15 | mycam = ONVIFCamera("192.168.0.112", 80, "admin", "12345") 16 | await mycam.update_xaddrs() 17 | media_service = mycam.create_media_service() 18 | 19 | profiles = await media_service.GetProfiles() 20 | 21 | # Use the first profile and Profiles have at least one 22 | token = profiles[0].token 23 | 24 | # Get all video encoder configurations 25 | configurations_list = await media_service.GetVideoEncoderConfigurations() 26 | 27 | # Use the first profile and Profiles have at least one 28 | video_encoder_configuration = configurations_list[0] 29 | 30 | # Get video encoder configuration options 31 | options = await media_service.GetVideoEncoderConfigurationOptions( 32 | {"ProfileToken": token} 33 | ) 34 | 35 | # Setup stream configuration 36 | video_encoder_configuration.Encoding = "H264" 37 | # Setup Resolution 38 | video_encoder_configuration.Resolution.Width = options.H264.ResolutionsAvailable[ 39 | 0 40 | ].Width 41 | video_encoder_configuration.Resolution.Height = options.H264.ResolutionsAvailable[ 42 | 0 43 | ].Height 44 | # Setup Quality 45 | video_encoder_configuration.Quality = options.QualityRange.Min 46 | # Setup FramRate 47 | video_encoder_configuration.RateControl.FrameRateLimit = ( 48 | options.H264.FrameRateRange.Min 49 | ) 50 | # Setup EncodingInterval 51 | video_encoder_configuration.RateControl.EncodingInterval = ( 52 | options.H264.EncodingIntervalRange.Min 53 | ) 54 | # Setup Bitrate 55 | video_encoder_configuration.RateControl.BitrateLimit = ( 56 | options.Extension.H264[0].BitrateRange[0].Min[0] 57 | ) 58 | 59 | # Create request type instance 60 | request = media_service.create_type("SetVideoEncoderConfiguration") 61 | request.Configuration = video_encoder_configuration 62 | # ForcePersistence is obsolete and should always be assumed to be True 63 | request.ForcePersistence = True 64 | 65 | # Set the video encoder configuration 66 | # await media_service.SetVideoEncoderConfiguration(request) 67 | await mycam.close() 68 | 69 | 70 | if __name__ == "__main__": 71 | loop = asyncio.get_event_loop() 72 | loop.run_until_complete(media_profile_configuration()) 73 | -------------------------------------------------------------------------------- /onvif/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize onvif.""" 2 | 3 | import zeep 4 | 5 | from onvif.client import SERVICES, ONVIFCamera, ONVIFService 6 | from onvif.exceptions import ( 7 | ERR_ONVIF_BUILD, 8 | ERR_ONVIF_PROTOCOL, 9 | ERR_ONVIF_UNKNOWN, 10 | ERR_ONVIF_WSDL, 11 | ONVIFError, 12 | ) 13 | 14 | 15 | def zeep_pythonvalue(self, xmlvalue): 16 | """Monkey patch zeep.""" 17 | return xmlvalue 18 | 19 | 20 | # pylint: disable=no-member 21 | zeep.xsd.simple.AnySimpleType.pythonvalue = zeep_pythonvalue 22 | 23 | __all__ = ( 24 | "ONVIFService", 25 | "ONVIFCamera", 26 | "ONVIFError", 27 | "ERR_ONVIF_UNKNOWN", 28 | "ERR_ONVIF_PROTOCOL", 29 | "ERR_ONVIF_WSDL", 30 | "ERR_ONVIF_BUILD", 31 | "SERVICES", 32 | ) 33 | -------------------------------------------------------------------------------- /onvif/const.py: -------------------------------------------------------------------------------- 1 | """ONVIF constants.""" 2 | 3 | DEFAULT_ATTEMPTS = 2 4 | KEEPALIVE_EXPIRY = 4 5 | BACKOFF_TIME = KEEPALIVE_EXPIRY + 0.5 6 | -------------------------------------------------------------------------------- /onvif/definition.py: -------------------------------------------------------------------------------- 1 | """ONVIF Service Definitions""" 2 | 3 | SERVICES = { 4 | "devicemgmt": { 5 | "ns": "http://www.onvif.org/ver10/device/wsdl", 6 | "wsdl": "devicemgmt.wsdl", 7 | "binding": "DeviceBinding", 8 | }, 9 | "media": { 10 | "ns": "http://www.onvif.org/ver10/media/wsdl", 11 | "wsdl": "media.wsdl", 12 | "binding": "MediaBinding", 13 | }, 14 | "ptz": { 15 | "ns": "http://www.onvif.org/ver20/ptz/wsdl", 16 | "wsdl": "ptz.wsdl", 17 | "binding": "PTZBinding", 18 | }, 19 | "imaging": { 20 | "ns": "http://www.onvif.org/ver20/imaging/wsdl", 21 | "wsdl": "imaging.wsdl", 22 | "binding": "ImagingBinding", 23 | }, 24 | "deviceio": { 25 | "ns": "http://www.onvif.org/ver10/deviceIO/wsdl", 26 | "wsdl": "deviceio.wsdl", 27 | "binding": "DeviceIOBinding", 28 | }, 29 | "events": { 30 | "ns": "http://www.onvif.org/ver10/events/wsdl", 31 | "wsdl": "events.wsdl", 32 | "binding": "EventBinding", 33 | }, 34 | "pullpoint": { 35 | "ns": "http://www.onvif.org/ver10/events/wsdl", 36 | "wsdl": "events.wsdl", 37 | "binding": "PullPointSubscriptionBinding", 38 | }, 39 | "notification": { 40 | "ns": "http://www.onvif.org/ver10/events/wsdl", 41 | "wsdl": "events.wsdl", 42 | "binding": "NotificationProducerBinding", 43 | }, 44 | "subscription": { 45 | "ns": "http://www.onvif.org/ver10/events/wsdl", 46 | "wsdl": "events.wsdl", 47 | "binding": "SubscriptionManagerBinding", 48 | }, 49 | "analytics": { 50 | "ns": "http://www.onvif.org/ver20/analytics/wsdl", 51 | "wsdl": "analytics.wsdl", 52 | "binding": "AnalyticsEngineBinding", 53 | }, 54 | "recording": { 55 | "ns": "http://www.onvif.org/ver10/recording/wsdl", 56 | "wsdl": "recording.wsdl", 57 | "binding": "RecordingBinding", 58 | }, 59 | "search": { 60 | "ns": "http://www.onvif.org/ver10/search/wsdl", 61 | "wsdl": "search.wsdl", 62 | "binding": "SearchBinding", 63 | }, 64 | "replay": { 65 | "ns": "http://www.onvif.org/ver10/replay/wsdl", 66 | "wsdl": "replay.wsdl", 67 | "binding": "ReplayBinding", 68 | }, 69 | "receiver": { 70 | "ns": "http://www.onvif.org/ver10/receiver/wsdl", 71 | "wsdl": "receiver.wsdl", 72 | "binding": "ReceiverBinding", 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /onvif/exceptions.py: -------------------------------------------------------------------------------- 1 | """Core exceptions raised by the ONVIF Client""" 2 | 3 | # Error codes setting 4 | # Error unknown, e.g, HTTP errors 5 | ERR_ONVIF_UNKNOWN = 1 6 | # Protocol error returned by WebService, 7 | # e.g:DataEncodingUnknown, MissingAttr, InvalidArgs, ... 8 | ERR_ONVIF_PROTOCOL = 2 9 | # Error about WSDL instance 10 | ERR_ONVIF_WSDL = 3 11 | # Error about Build 12 | ERR_ONVIF_BUILD = 4 13 | 14 | 15 | class ONVIFError(Exception): 16 | """ONVIF Exception class.""" 17 | 18 | def __init__(self, err): 19 | self.reason = "Unknown error: " + str(err) 20 | self.code = ERR_ONVIF_UNKNOWN 21 | super().__init__(err) 22 | 23 | def __str__(self): 24 | return self.reason 25 | 26 | 27 | class ONVIFTimeoutError(ONVIFError): 28 | """ONVIF Timeout Exception class.""" 29 | 30 | def __init__(self, err): 31 | super().__init__(err) 32 | 33 | 34 | class ONVIFAuthError(ONVIFError): 35 | """ONVIF Authentication Exception class.""" 36 | 37 | def __init__(self, err): 38 | super().__init__(err) 39 | -------------------------------------------------------------------------------- /onvif/managers.py: -------------------------------------------------------------------------------- 1 | """ONVIF Managers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import datetime as dt 7 | import logging 8 | from abc import abstractmethod 9 | from collections.abc import Callable 10 | from typing import TYPE_CHECKING, Any 11 | 12 | from zeep.exceptions import Fault, XMLParseError, XMLSyntaxError 13 | from zeep.loader import parse_xml 14 | from zeep.wsdl.bindings.soap import SoapOperation 15 | 16 | import aiohttp 17 | from onvif.exceptions import ONVIFError 18 | 19 | from .settings import DEFAULT_SETTINGS 20 | from .transport import ASYNC_TRANSPORT 21 | from .util import normalize_url, stringify_onvif_error 22 | from .wrappers import retry_connection_error 23 | 24 | logger = logging.getLogger("onvif") 25 | 26 | 27 | _RENEWAL_PERCENTAGE = 0.8 28 | 29 | SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, aiohttp.ClientError) 30 | RENEW_ERRORS = (ONVIFError, aiohttp.ClientError, XMLParseError, *SUBSCRIPTION_ERRORS) 31 | SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR = dt.timedelta(seconds=40) 32 | 33 | # If the camera returns a subscription with a termination time that is less than 34 | # this value, we will use this value instead to prevent subscribing over and over 35 | # again. 36 | MINIMUM_SUBSCRIPTION_SECONDS = 60.0 37 | 38 | if TYPE_CHECKING: 39 | from onvif.client import ONVIFCamera, ONVIFService 40 | 41 | 42 | class BaseManager: 43 | """Base class for notification and pull point managers.""" 44 | 45 | def __init__( 46 | self, 47 | device: ONVIFCamera, 48 | interval: dt.timedelta, 49 | subscription_lost_callback: Callable[[], None], 50 | ) -> None: 51 | """Initialize the notification processor.""" 52 | self._device = device 53 | self._interval = interval 54 | self._subscription: ONVIFService | None = None 55 | self._restart_or_renew_task: asyncio.Task | None = None 56 | self._loop = asyncio.get_event_loop() 57 | self._shutdown = False 58 | self._subscription_lost_callback = subscription_lost_callback 59 | self._cancel_subscription_renew: asyncio.TimerHandle | None = None 60 | self._service: ONVIFService | None = None 61 | 62 | @property 63 | def closed(self) -> bool: 64 | """Return True if the manager is closed.""" 65 | return not self._subscription or self._subscription.transport.session.closed 66 | 67 | async def start(self) -> None: 68 | """Setup the manager.""" 69 | renewal_call_at = await self._start() 70 | self._schedule_subscription_renew(renewal_call_at) 71 | return self._subscription 72 | 73 | def pause(self) -> None: 74 | """Pause the manager.""" 75 | self._cancel_renewals() 76 | 77 | def resume(self) -> None: 78 | """Resume the manager.""" 79 | self._schedule_subscription_renew(self._loop.time()) 80 | 81 | async def stop(self) -> None: 82 | """Stop the manager.""" 83 | logger.debug("%s: Stop the notification manager", self._device.host) 84 | self._cancel_renewals() 85 | assert self._subscription, "Call start first" 86 | await self._subscription.Unsubscribe() 87 | 88 | async def shutdown(self) -> None: 89 | """ 90 | Shutdown the manager. 91 | 92 | This method is irreversible. 93 | """ 94 | self._shutdown = True 95 | if self._restart_or_renew_task: 96 | self._restart_or_renew_task.cancel() 97 | logger.debug("%s: Shutdown the notification manager", self._device.host) 98 | await self.stop() 99 | 100 | @abstractmethod 101 | async def _start(self) -> float: 102 | """Setup the processor. Returns the next renewal call at time.""" 103 | 104 | async def set_synchronization_point(self) -> float: 105 | """Set the synchronization point.""" 106 | try: 107 | await self._service.SetSynchronizationPoint() 108 | except (TimeoutError, Fault, aiohttp.ClientError, TypeError): 109 | logger.debug("%s: SetSynchronizationPoint failed", self._service.url) 110 | 111 | def _cancel_renewals(self) -> None: 112 | """Cancel any pending renewals.""" 113 | if self._cancel_subscription_renew: 114 | self._cancel_subscription_renew.cancel() 115 | self._cancel_subscription_renew = None 116 | 117 | def _calculate_next_renewal_call_at(self, result: Any | None) -> float: 118 | """Calculate the next renewal call_at.""" 119 | current_time: dt.datetime | None = result.CurrentTime 120 | termination_time: dt.datetime | None = result.TerminationTime 121 | if termination_time and current_time: 122 | delay = termination_time - current_time 123 | else: 124 | delay = self._interval 125 | delay_seconds = ( 126 | max(delay.total_seconds(), MINIMUM_SUBSCRIPTION_SECONDS) 127 | * _RENEWAL_PERCENTAGE 128 | ) 129 | logger.debug( 130 | "%s: Renew notification subscription in %s seconds", 131 | self._device.host, 132 | delay_seconds, 133 | ) 134 | return self._loop.time() + delay_seconds 135 | 136 | def _schedule_subscription_renew(self, when: float) -> None: 137 | """Schedule notify subscription renewal.""" 138 | self._cancel_renewals() 139 | self._cancel_subscription_renew = self._loop.call_at( 140 | when, 141 | self._run_restart_or_renew, 142 | ) 143 | 144 | def _run_restart_or_renew(self) -> None: 145 | """Create a background task.""" 146 | if self._restart_or_renew_task and not self._restart_or_renew_task.done(): 147 | logger.debug("%s: Notify renew already in progress", self._device.host) 148 | return 149 | self._restart_or_renew_task = asyncio.create_task( 150 | self._renew_or_restart_subscription() 151 | ) 152 | 153 | async def _restart_subscription(self) -> float: 154 | """Restart the notify subscription assuming the camera rebooted.""" 155 | self._cancel_renewals() 156 | return await self._start() 157 | 158 | @retry_connection_error() 159 | async def _call_subscription_renew(self) -> float: 160 | """Call notify subscription Renew.""" 161 | device = self._device 162 | logger.debug("%s: Renew the notification manager", device.host) 163 | return self._calculate_next_renewal_call_at( 164 | await self._subscription.Renew( 165 | device.get_next_termination_time(self._interval) 166 | ) 167 | ) 168 | 169 | async def _renew_subscription(self) -> float | None: 170 | """Renew notify subscription.""" 171 | if self.closed or self._shutdown: 172 | return None 173 | try: 174 | return await self._call_subscription_renew() 175 | except RENEW_ERRORS as err: 176 | self._subscription_lost_callback() 177 | logger.debug( 178 | "%s: Failed to renew notify subscription %s", 179 | self._device.host, 180 | stringify_onvif_error(err), 181 | ) 182 | return None 183 | 184 | async def _renew_or_restart_subscription(self) -> None: 185 | """Renew or start notify subscription.""" 186 | if self._shutdown: 187 | return 188 | renewal_call_at = None 189 | try: 190 | renewal_call_at = ( 191 | await self._renew_subscription() or await self._restart_subscription() 192 | ) 193 | finally: 194 | self._schedule_subscription_renew( 195 | renewal_call_at 196 | or self._loop.time() 197 | + SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR.total_seconds() 198 | ) 199 | 200 | 201 | class NotificationManager(BaseManager): 202 | """Manager to process notifications.""" 203 | 204 | def __init__( 205 | self, 206 | device: ONVIFCamera, 207 | address: str, 208 | interval: dt.timedelta, 209 | subscription_lost_callback: Callable[[], None], 210 | ) -> None: 211 | """Initialize the notification processor.""" 212 | self._address = address 213 | self._operation: SoapOperation | None = None 214 | super().__init__(device, interval, subscription_lost_callback) 215 | 216 | async def _start(self) -> float: 217 | """ 218 | Start the notification processor. 219 | 220 | Returns the next renewal call at time. 221 | """ 222 | device = self._device 223 | logger.debug("%s: Setup the notification manager", device.host) 224 | notify_service = await device.create_notification_service() 225 | time_str = device.get_next_termination_time(self._interval) 226 | result = await notify_service.Subscribe( 227 | { 228 | "InitialTerminationTime": time_str, 229 | "ConsumerReference": {"Address": self._address}, 230 | } 231 | ) 232 | # pylint: disable=protected-access 233 | device.xaddrs["http://www.onvif.org/ver10/events/wsdl/NotificationConsumer"] = ( 234 | normalize_url(result.SubscriptionReference.Address._value_1) 235 | ) 236 | # Create subscription manager 237 | # 5.2.3 BASIC NOTIFICATION INTERFACE - NOTIFY 238 | # Call SetSynchronizationPoint to generate a notification message 239 | # to ensure the webhooks are working. 240 | # 241 | # If this fails this is OK as it just means we will switch 242 | # to webhook later when the first notification is received. 243 | self._service = await self._device.create_onvif_service( 244 | "pullpoint", port_type="NotificationConsumer" 245 | ) 246 | self._operation = self._service.document.bindings[ 247 | self._service.binding_name 248 | ].get("PullMessages") 249 | self._subscription = await device.create_subscription_service( 250 | "NotificationConsumer" 251 | ) 252 | if device.has_broken_relative_time( 253 | self._interval, 254 | result.CurrentTime, 255 | result.TerminationTime, 256 | ): 257 | # If we determine the device has broken relative timestamps, we switch 258 | # to using absolute timestamps and renew the subscription. 259 | result = await self._subscription.Renew( 260 | device.get_next_termination_time(self._interval) 261 | ) 262 | renewal_call_at = self._calculate_next_renewal_call_at(result) 263 | logger.debug("%s: Start the notification manager", self._device.host) 264 | return renewal_call_at 265 | 266 | def process(self, content: bytes) -> Any | None: 267 | """Process a notification message.""" 268 | if not self._operation: 269 | logger.debug("%s: Notifications not setup", self._device.host) 270 | return 271 | try: 272 | envelope = parse_xml( 273 | content, # type: ignore[arg-type] 274 | ASYNC_TRANSPORT, 275 | settings=DEFAULT_SETTINGS, 276 | ) 277 | except XMLSyntaxError: 278 | try: 279 | envelope = parse_xml( 280 | content.decode("utf-8", "replace").encode("utf-8"), 281 | ASYNC_TRANSPORT, 282 | settings=DEFAULT_SETTINGS, 283 | ) 284 | except XMLSyntaxError as exc: 285 | logger.error("Received invalid XML: %s (%s)", exc, content) 286 | return None 287 | return self._operation.process_reply(envelope) 288 | 289 | 290 | class PullPointManager(BaseManager): 291 | """Manager for PullPoint.""" 292 | 293 | async def _start(self) -> float: 294 | """ 295 | Start the PullPoint manager. 296 | 297 | Returns the next renewal call at time. 298 | """ 299 | device = self._device 300 | logger.debug("%s: Setup the PullPoint manager", device.host) 301 | events_service = await device.create_events_service() 302 | result = await events_service.CreatePullPointSubscription( 303 | { 304 | "InitialTerminationTime": device.get_next_termination_time( 305 | self._interval 306 | ), 307 | } 308 | ) 309 | # pylint: disable=protected-access 310 | device.xaddrs[ 311 | "http://www.onvif.org/ver10/events/wsdl/PullPointSubscription" 312 | ] = normalize_url(result.SubscriptionReference.Address._value_1) 313 | # Create subscription manager 314 | self._subscription = await device.create_subscription_service( 315 | "PullPointSubscription" 316 | ) 317 | # Create the service that will be used to pull messages from the device. 318 | self._service = await device.create_pullpoint_service() 319 | if device.has_broken_relative_time( 320 | self._interval, result.CurrentTime, result.TerminationTime 321 | ): 322 | # If we determine the device has broken relative timestamps, we switch 323 | # to using absolute timestamps and renew the subscription. 324 | result = await self._subscription.Renew( 325 | device.get_next_termination_time(self._interval) 326 | ) 327 | renewal_call_at = self._calculate_next_renewal_call_at(result) 328 | logger.debug("%s: Start the notification manager", self._device.host) 329 | return renewal_call_at 330 | 331 | def get_service(self) -> ONVIFService: 332 | """Return the pullpoint service.""" 333 | return self._service 334 | -------------------------------------------------------------------------------- /onvif/settings.py: -------------------------------------------------------------------------------- 1 | """ONVIF settings.""" 2 | 3 | from zeep.client import Settings 4 | 5 | DEFAULT_SETTINGS = Settings() 6 | DEFAULT_SETTINGS.strict = False 7 | DEFAULT_SETTINGS.xml_huge_tree = True 8 | -------------------------------------------------------------------------------- /onvif/transport.py: -------------------------------------------------------------------------------- 1 | """ONVIF transport.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os.path 6 | 7 | from zeep.transports import Transport 8 | 9 | from .util import path_isfile 10 | 11 | 12 | class AsyncSafeTransport(Transport): 13 | """A transport that blocks all remote I/O for zeep.""" 14 | 15 | def load(self, url: str) -> None: 16 | """Load the given XML document.""" 17 | if not path_isfile(url): 18 | raise RuntimeError(f"Loading {url} is not supported in async mode") 19 | with open(os.path.expanduser(url), "rb") as fh: 20 | return fh.read() 21 | 22 | 23 | ASYNC_TRANSPORT = AsyncSafeTransport() 24 | -------------------------------------------------------------------------------- /onvif/types.py: -------------------------------------------------------------------------------- 1 | """ONVIF types.""" 2 | 3 | from datetime import datetime, timedelta, time 4 | import ciso8601 5 | from zeep.xsd.types.builtins import DateTime, treat_whitespace, Time 6 | import isodate 7 | 8 | 9 | def _try_parse_datetime(value: str) -> datetime | None: 10 | try: 11 | return ciso8601.parse_datetime(value) 12 | except ValueError: 13 | pass 14 | 15 | try: 16 | return isodate.parse_datetime(value) 17 | except ValueError: 18 | pass 19 | 20 | return None 21 | 22 | 23 | def _try_fix_time_overflow(time: str) -> tuple[str, dict[str, int]]: 24 | """Some camera will overflow time so we need to fix it. 25 | 26 | To do this we calculate the offset beyond the maximum value 27 | and then add it to the current time as a timedelta. 28 | """ 29 | offset: dict[str, int] = {} 30 | hour = int(time[0:2]) 31 | if hour > 23: 32 | offset["hours"] = hour - 23 33 | hour = 23 34 | minute = int(time[3:5]) 35 | if minute > 59: 36 | offset["minutes"] = minute - 59 37 | minute = 59 38 | second = int(time[6:8]) 39 | if second > 59: 40 | offset["seconds"] = second - 59 41 | second = 59 42 | time_trailer = time[8:] 43 | return f"{hour:02d}:{minute:02d}:{second:02d}{time_trailer}", offset 44 | 45 | 46 | # see https://github.com/mvantellingen/python-zeep/pull/1370 47 | class FastDateTime(DateTime): 48 | """Fast DateTime that supports timestamps with - instead of T.""" 49 | 50 | @treat_whitespace("collapse") 51 | def pythonvalue(self, value: str) -> datetime: 52 | """Convert the xml value into a python value.""" 53 | if len(value) > 10 and value[10] == "-": # 2010-01-01-00:00:00... 54 | value[10] = "T" 55 | if len(value) > 10 and value[11] == "-": # 2023-05-15T-07:10:32Z... 56 | value = value[:11] + value[12:] 57 | # Determine based on the length of the value if it only contains a date 58 | # lazy hack ;-) 59 | if len(value) == 10: 60 | value += "T00:00:00" 61 | elif (len(value) in (19, 20, 26)) and value[10] == " ": 62 | value = "T".join(value.split(" ")) 63 | 64 | if dt := _try_parse_datetime(value): 65 | return dt 66 | 67 | # Some cameras overflow the hours/minutes/seconds 68 | # For example, 2024-08-17T00:61:16Z so we need 69 | # to fix the overflow 70 | date, _, time = value.partition("T") 71 | try: 72 | fixed_time, offset = _try_fix_time_overflow(time) 73 | except ValueError: 74 | return ciso8601.parse_datetime(value) 75 | 76 | if dt := _try_parse_datetime(f"{date}T{fixed_time}"): 77 | return dt + timedelta(**offset) 78 | 79 | return ciso8601.parse_datetime(value) 80 | 81 | 82 | class ForgivingTime(Time): 83 | """ForgivingTime.""" 84 | 85 | @treat_whitespace("collapse") 86 | def pythonvalue(self, value: str) -> time: 87 | try: 88 | return isodate.parse_time(value) 89 | except ValueError: 90 | pass 91 | 92 | # Some cameras overflow the hours/minutes/seconds 93 | # For example, 00:61:16Z so we need 94 | # to fix the overflow 95 | try: 96 | fixed_time, offset = _try_fix_time_overflow(value) 97 | except ValueError: 98 | return isodate.parse_time(value) 99 | if fixed_dt := _try_parse_datetime(f"2024-01-15T{fixed_time}Z"): 100 | return (fixed_dt + timedelta(**offset)).time() 101 | return isodate.parse_time(value) 102 | -------------------------------------------------------------------------------- /onvif/util.py: -------------------------------------------------------------------------------- 1 | """ONVIF util.""" 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import datetime as dt 7 | import os 8 | import ssl 9 | from functools import lru_cache, partial 10 | from typing import Any 11 | from urllib.parse import ParseResultBytes, urlparse, urlunparse 12 | 13 | from zeep.exceptions import Fault 14 | 15 | from multidict import CIMultiDict 16 | from yarl import URL 17 | 18 | utcnow: partial[dt.datetime] = partial(dt.datetime.now, dt.timezone.utc) 19 | 20 | # This does blocking I/O (stat) so we cache the result 21 | # to minimize the impact of the blocking I/O. 22 | path_isfile = lru_cache(maxsize=128)(os.path.isfile) 23 | 24 | _CREDENTIAL_KEYS = ("username", "password", "user", "pass") 25 | 26 | 27 | def normalize_url(url: bytes | str | None) -> str | None: 28 | """ 29 | Normalize URL. 30 | 31 | Some cameras respond with http://192.168.1.106:8106:8106/onvif/Subscription?Idx=43 32 | https://github.com/home-assistant/core/issues/92603#issuecomment-1537213126 33 | """ 34 | if url is None: 35 | return None 36 | parsed = urlparse(url) 37 | # If the URL is not a string, return None 38 | if isinstance(parsed, ParseResultBytes): 39 | return None 40 | if "[" not in parsed.netloc and parsed.netloc.count(":") > 1: 41 | net_location = parsed.netloc.split(":", 3) 42 | net_location.pop() 43 | return urlunparse(parsed._replace(netloc=":".join(net_location))) 44 | return url 45 | 46 | 47 | def extract_subcodes_as_strings(subcodes: Any) -> list[str]: 48 | """Stringify ONVIF subcodes.""" 49 | if isinstance(subcodes, list): 50 | return [code.text if hasattr(code, "text") else str(code) for code in subcodes] 51 | return [str(subcodes)] 52 | 53 | 54 | def stringify_onvif_error(error: Exception) -> str: 55 | """Stringify ONVIF error.""" 56 | if isinstance(error, Fault): 57 | message = error.message 58 | if error.detail is not None: # checking true is deprecated 59 | # Detail may be a bytes object, so we need to convert it to string 60 | if isinstance(error.detail, bytes): 61 | detail = error.detail.decode("utf-8", "replace") 62 | else: 63 | detail = str(error.detail) 64 | message += ": " + detail 65 | if error.code is not None: # checking true is deprecated 66 | message += f" (code:{error.code})" 67 | if error.subcodes is not None: # checking true is deprecated 68 | message += ( 69 | f" (subcodes:{','.join(extract_subcodes_as_strings(error.subcodes))})" 70 | ) 71 | if error.actor: 72 | message += f" (actor:{error.actor})" 73 | else: 74 | message = str(error) 75 | return message or f"Device sent empty error with type {type(error)}" 76 | 77 | 78 | def is_auth_error(error: Exception) -> bool: 79 | """ 80 | Return True if error is an authentication error. 81 | 82 | Most of the tested cameras do not return a proper error code when 83 | authentication fails, so we need to check the error message as well. 84 | """ 85 | if not isinstance(error, Fault): 86 | return False 87 | return ( 88 | any( 89 | "NotAuthorized" in code 90 | for code in extract_subcodes_as_strings(error.subcodes) 91 | ) 92 | or "auth" in stringify_onvif_error(error).lower() 93 | ) 94 | 95 | 96 | def create_no_verify_ssl_context() -> ssl.SSLContext: 97 | """ 98 | Return an SSL context that does not verify the server certificate. 99 | This is a copy of aiohttp's create_default_context() function, with the 100 | ssl verify turned off and old SSL versions enabled. 101 | 102 | https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 103 | """ 104 | sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 105 | sslcontext.check_hostname = False 106 | sslcontext.verify_mode = ssl.CERT_NONE 107 | # Allow all ciphers rather than only Python 3.10 default 108 | sslcontext.set_ciphers("DEFAULT") 109 | with contextlib.suppress(AttributeError): 110 | # This only works for OpenSSL >= 1.0.0 111 | sslcontext.options |= ssl.OP_NO_COMPRESSION 112 | sslcontext.set_default_verify_paths() 113 | # ssl.OP_LEGACY_SERVER_CONNECT is only available in Python 3.12a4+ 114 | sslcontext.options |= getattr(ssl, "OP_LEGACY_SERVER_CONNECT", 0x4) 115 | return sslcontext 116 | 117 | 118 | def strip_user_pass_url(url: str) -> str: 119 | """Strip password from URL.""" 120 | parsed_url = URL(url) 121 | 122 | # First strip userinfo (user:pass@) from URL 123 | if parsed_url.user or parsed_url.password: 124 | parsed_url = parsed_url.with_user(None) 125 | 126 | # Then strip credentials from query parameters 127 | query = parsed_url.query 128 | new_query: CIMultiDict | None = None 129 | for key in _CREDENTIAL_KEYS: 130 | if key in query: 131 | if new_query is None: 132 | new_query = CIMultiDict(parsed_url.query) 133 | new_query.popall(key) 134 | if new_query is not None: 135 | return str(parsed_url.with_query(new_query)) 136 | return str(parsed_url) 137 | 138 | 139 | def obscure_user_pass_url(url: str) -> str: 140 | """Obscure user and password from URL.""" 141 | parsed_url = URL(url) 142 | 143 | # First obscure userinfo if present 144 | if parsed_url.user: 145 | # Keep the user but obscure the password 146 | if parsed_url.password: 147 | parsed_url = parsed_url.with_password("********") 148 | else: 149 | # If only user is present, obscure it 150 | parsed_url = parsed_url.with_user("********") 151 | 152 | # Then obscure credentials in query parameters 153 | query = parsed_url.query 154 | new_query: CIMultiDict | None = None 155 | for key in _CREDENTIAL_KEYS: 156 | if key in query: 157 | if new_query is None: 158 | new_query = CIMultiDict(parsed_url.query) 159 | new_query.popall(key) 160 | new_query[key] = "********" 161 | if new_query is not None: 162 | return str(parsed_url.with_query(new_query)) 163 | return str(parsed_url) 164 | -------------------------------------------------------------------------------- /onvif/version.txt: -------------------------------------------------------------------------------- 1 | 4.0.1 2 | -------------------------------------------------------------------------------- /onvif/wrappers.py: -------------------------------------------------------------------------------- 1 | """ONVIF Client wrappers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from collections.abc import Awaitable, Callable 8 | from typing import ParamSpec, TypeVar 9 | 10 | import aiohttp 11 | 12 | from .const import BACKOFF_TIME, DEFAULT_ATTEMPTS 13 | 14 | P = ParamSpec("P") 15 | T = TypeVar("T") 16 | logger = logging.getLogger("onvif") 17 | 18 | 19 | def retry_connection_error( 20 | attempts: int = DEFAULT_ATTEMPTS, 21 | exception: type[Exception] = aiohttp.ClientError, 22 | ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: 23 | """Define a wrapper to retry on connection error.""" 24 | 25 | def _decorator_retry_connection_error( 26 | func: Callable[P, Awaitable[T]], 27 | ) -> Callable[P, Awaitable[T]]: 28 | """ 29 | Define a wrapper to retry on connection error. 30 | 31 | The remote server is allowed to disconnect us any time so 32 | we need to retry the operation. 33 | """ 34 | 35 | async def _async_wrap_connection_error_retry( # type: ignore[return] 36 | *args: P.args, **kwargs: P.kwargs 37 | ) -> T: 38 | for attempt in range(attempts): 39 | try: 40 | return await func(*args, **kwargs) 41 | except exception as ex: 42 | # 43 | # We should only need to retry on ServerDisconnectedError but some cameras 44 | # are flakey and sometimes do not respond to the Renew request so we 45 | # retry on ClientError as well. 46 | # 47 | # For ServerDisconnectedError: 48 | # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server 49 | # to close the connection at any time, we treat this as a normal and try again 50 | # once since we do not want to declare the camera as not supporting PullPoint 51 | # if it just happened to close the connection at the wrong time. 52 | if attempt == attempts - 1: 53 | raise 54 | logger.debug( 55 | "Error: %s while calling %s, backing off: %s, retrying...", 56 | ex, 57 | func, 58 | BACKOFF_TIME, 59 | exc_info=True, 60 | ) 61 | await asyncio.sleep(BACKOFF_TIME) 62 | 63 | return _async_wrap_connection_error_retry 64 | 65 | return _decorator_retry_connection_error 66 | -------------------------------------------------------------------------------- /onvif/wsa.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from lxml import etree 4 | from lxml.builder import ElementMaker 5 | from zeep import ns 6 | from zeep.plugins import Plugin 7 | from zeep.wsdl.utils import get_or_create_header 8 | 9 | WSA = ElementMaker(namespace=ns.WSA, nsmap={"wsa": ns.WSA}) 10 | 11 | 12 | class WsAddressingIfMissingPlugin(Plugin): 13 | nsmap = {"wsa": ns.WSA} 14 | 15 | def __init__(self, address_url: str = None): 16 | self.address_url = address_url 17 | 18 | def egress(self, envelope, http_headers, operation, binding_options): 19 | """Apply the ws-addressing headers to the given envelope.""" 20 | header = get_or_create_header(envelope) 21 | for elem in header: 22 | if (elem.prefix or "").startswith("wsa"): 23 | # WSA header already exists 24 | return envelope, http_headers 25 | 26 | wsa_action = operation.abstract.wsa_action 27 | if not wsa_action: 28 | wsa_action = operation.soapaction 29 | 30 | headers = [ 31 | WSA.Action(wsa_action), 32 | WSA.MessageID("urn:uuid:" + str(uuid.uuid4())), 33 | WSA.To(self.address_url or binding_options["address"]), 34 | ] 35 | header.extend(headers) 36 | 37 | # the top_nsmap kwarg was added in lxml 3.5.0 38 | if etree.LXML_VERSION[:2] >= (3, 5): 39 | etree.cleanup_namespaces( 40 | header, keep_ns_prefixes=header.nsmap, top_nsmap=self.nsmap 41 | ) 42 | else: 43 | etree.cleanup_namespaces(header) 44 | return envelope, http_headers 45 | -------------------------------------------------------------------------------- /onvif/wsdl/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy for packaging""" 2 | -------------------------------------------------------------------------------- /onvif/wsdl/addressing: -------------------------------------------------------------------------------- 1 |  2 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | If "Policy" elements from namespace "http://schemas.xmlsoap.org/ws/2002/12/policy#policy" are used, they must appear first (before any extensibility elements). 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /onvif/wsdl/bf-2.xsd: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 26 | 29 | 31 | 32 | 33 | Get access to the xml: attribute groups for xml:lang as declared on 'schema' 34 | and 'documentation' below 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 46 | 48 | 50 | 52 | 53 | 54 | 55 | 57 | 58 | 59 | 60 | 61 | 62 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /onvif/wsdl/envelope: -------------------------------------------------------------------------------- 1 | 2 | 3 | 35 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Prose in the spec does not specify that attributes are allowed on the Body element 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 'encodingStyle' indicates any canonicalization conventions followed in the contents of the containing element. For example, the value 'http://schemas.xmlsoap.org/soap/encoding/' indicates the pattern described in SOAP specification 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | Fault reporting structure 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /onvif/wsdl/imaging.wsdl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | The capabilities for the imaging service is returned in the Capabilities element. 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Indicates whether or not Image Stabilization feature is supported. 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Reference token to the VideoSource for which the ImagingSettings. 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ImagingSettings for the VideoSource that was requested. 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | Reference token to the VideoSource for which the imaging parameter options are requested. 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | Valid ranges for the imaging parameters that are categorized as device specific. 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | Reference to the VideoSource for the requested move (focus) operation. 121 | 122 | 123 | 124 | 125 | 126 | 127 | Content of the requested move (focus) operation. 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | Reference token to the VideoSource for the requested move options. 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | Valid ranges for the focus lens move options. 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | Reference token to the VideoSource where the focus movement should be stopped. 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | Reference token to the VideoSource where the imaging status should be requested. 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | Requested imaging status. 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | Returns the capabilities of the imaging service. The result is returned in a typed answer. 267 | 268 | 269 | 270 | 271 | Get the ImagingConfiguration for the requested VideoSource. 272 | 273 | 274 | 275 | 276 | Set the ImagingConfiguration for the requested VideoSource. 277 | 278 | 279 | 280 | 281 | This operation gets the valid ranges for the imaging parameters that have device specific ranges. 282 | This command is mandatory for all device implementing the imaging service. The command returns all supported parameters and their ranges 283 | such that these can be applied to the SetImagingSettings command.
284 | For read-only parameters which cannot be modified via the SetImagingSettings command only a single option or identical Min and Max values 285 | is provided.
286 | 287 | 288 |
289 | 290 | The Move command moves the focus lens in an absolute, a relative or in a continuous manner from its current position. 291 | The speed argument is optional for absolute and relative control, but required for continuous. If no speed argument is used, the default speed is used. 292 | Focus adjustments through this operation will turn off the autofocus. A device with support for remote focus control should support absolute, 293 | relative or continuous control through the Move operation. The supported MoveOpions are signalled via the GetMoveOptions command. 294 | At least one focus control capability is required for this operation to be functional.
295 | The move operation contains the following commands:
296 | Absolute – Requires position parameter and optionally takes a speed argument. A unitless type is used by default for focus positioning and speed. Optionally, if supported, the position may be requested in m-1 units.
297 | Relative – Requires distance parameter and optionally takes a speed argument. Negative distance means negative direction. 298 | Continuous – Requires a speed argument. Negative speed argument means negative direction. 299 |
300 | 301 | 302 |
303 | 304 | Imaging move operation options supported for the Video source. 305 | 306 | 307 | 308 | 309 | The Stop command stops all ongoing focus movements of the lense. A device with support for remote focus control as signalled via 310 | the GetMoveOptions supports this command.
The operation will not affect ongoing autofocus operation.
311 | 312 | 313 |
314 | 315 | Via this command the current status of the Move operation can be requested. Supported for this command is available if the support for the Move operation is signalled via GetMoveOptions. 316 | 317 | 318 | 319 |
320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 |
401 | -------------------------------------------------------------------------------- /onvif/wsdl/include: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /onvif/wsdl/r-2.xsd: -------------------------------------------------------------------------------- 1 | 2 | 17 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 49 | 50 | -------------------------------------------------------------------------------- /onvif/wsdl/receiver.wsdl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | The capabilities for the receiver service is returned in the Capabilities element. 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Indicates that the device can receive RTP multicast streams. 41 | 42 | 43 | 44 | 45 | Indicates that the device can receive RTP/TCP streams 46 | 47 | 48 | 49 | 50 | Indicates that the device can receive RTP/RTSP/TCP streams. 51 | 52 | 53 | 54 | 55 | The maximum number of receivers supported by the device. 56 | 57 | 58 | 59 | 60 | The maximum allowed length for RTSP URIs (Minimum and default value is 128 octet). 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | A list of all receivers that currently exist on the device. 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | The token of the receiver to be retrieved. 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | The details of the receiver. 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | The initial configuration for the new receiver. 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | The details of the receiver that was created. 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | The token of the receiver to be deleted. 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | The token of the receiver to be configured. 151 | 152 | 153 | 154 | 155 | The new configuration for the receiver. 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | The token of the receiver to be changed. 173 | 174 | 175 | 176 | 177 | The new receiver mode. Options available are: 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | The token of the receiver to be queried. 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | Description of the current receiver state. 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | Returns the capabilities of the receiver service. The result is returned in a typed answer. 264 | 265 | 266 | 267 | 268 | 269 | Lists all receivers currently present on a device. This operation is mandatory. 270 | 271 | 272 | 273 | 274 | 275 | 276 | Retrieves the details of a specific receiver. This operation is mandatory. 277 | 278 | 279 | 280 | 281 | 282 | 283 | Creates a new receiver. This operation is mandatory, although the service may 284 | raise a fault if the receiver cannot be created. 285 | 286 | 287 | 288 | 289 | 290 | 291 | Deletes an existing receiver. A receiver may be deleted only if it is not 292 | currently in use; otherwise a fault shall be raised. 293 | This operation is mandatory. 294 | 295 | 296 | 297 | 298 | 299 | 300 | Configures an existing receiver. This operation is mandatory. 301 | 302 | 303 | 304 | 305 | 306 | 307 | Sets the mode of the receiver without affecting the rest of its configuration. 308 | This operation is mandatory. 309 | 310 | 311 | 312 | 313 | 314 | 315 | Determines whether the receiver is currently disconnected, connected or 316 | attempting to connect. 317 | This operation is mandatory. 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | -------------------------------------------------------------------------------- /onvif/wsdl/remotediscovery.wsdl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /onvif/wsdl/replay.wsdl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | The capabilities for the replay service is returned in the Capabilities element. 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Indicator that the Device supports reverse playback as defined in the ONVIF Streaming Specification. 41 | 42 | 43 | 44 | 45 | The list contains two elements defining the minimum and maximum valid values supported as session timeout in seconds. 46 | 47 | 48 | 49 | 50 | Indicates support for RTP/RTSP/TCP. 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | Specifies the connection parameters to be used for the stream. The URI that is returned may depend on these parameters. 63 | 64 | 65 | 66 | 67 | The identifier of the recording to be streamed. 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | The URI to which the client should connect in order to stream the recording. 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | Description of the new replay configuration parameters. 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | The current replay configuration parameters. 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | Returns the capabilities of the replay service. The result is returned in a typed answer. 147 | 148 | 149 | 150 | 151 | 152 | Requests a URI that can be used to initiate playback of a recorded stream 153 | using RTSP as the control protocol. The URI is valid only as it is 154 | specified in the response. 155 | This operation is mandatory. 156 | 157 | 158 | 159 | 160 | 161 | 162 | Returns the current configuration of the replay service. 163 | This operation is mandatory. 164 | 165 | 166 | 167 | 168 | 169 | 170 | Changes the current configuration of the replay service. 171 | This operation is mandatory. 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | -------------------------------------------------------------------------------- /onvif/wsdl/rw-2.wsdl: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 25 | 26 | 27 | 28 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /onvif/wsdl/t-1.xsd: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 67 | 68 | 69 | 71 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 97 | 99 | 100 | 101 | 102 | 103 | 104 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | TopicPathExpression ::= TopicPath ( '|' TopicPath )* 143 | TopicPath ::= RootTopic ChildTopicExpression* 144 | RootTopic ::= NamespacePrefix? ('//')? (NCName | '*') 145 | NamespacePrefix ::= NCName ':' 146 | ChildTopicExpression ::= '/' '/'? (QName | NCName | '*'| '.') 147 | 148 | 149 | 150 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | The pattern allows strings matching the following EBNF: 161 | ConcreteTopicPath ::= RootTopic ChildTopic* 162 | RootTopic ::= QName 163 | ChildTopic ::= '/' (QName | NCName) 164 | 165 | 166 | 167 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | The pattern allows strings matching the following EBNF: 178 | RootTopic ::= QName 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | -------------------------------------------------------------------------------- /onvif/wsdl/types.xsd: -------------------------------------------------------------------------------- 1 | 2 | 28 | 33 | 34 | 35 | 36 | 37 | Type used to reference logical and physical entities. 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | General datastructure referenced by a token. 49 | Should be used as extension base. 50 | 51 | 52 | 53 | 54 | A service-unique identifier of the item. 55 | 56 | 57 | 58 | 59 | 60 | 61 | Type used for names of logical and physical entities. 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Description is optional and the maximum length is device specific. 73 | If the length is more than maximum length, it is silently chopped to the maximum length 74 | supported by the device/service (which may be 0). 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /onvif/wsdl/ws-addr.xsd: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /onvif/wsdl/ws-discovery.xsd: -------------------------------------------------------------------------------- 1 | 2 | 53 | 60 | 61 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 129 | 130 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 170 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 263 | 264 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | -------------------------------------------------------------------------------- /onvif/wsdl/xml.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 |
11 |

About the XML namespace

12 | 13 |
14 |

15 | This schema document describes the XML namespace, in a form 16 | suitable for import by other schema documents. 17 |

18 |

19 | See 20 | http://www.w3.org/XML/1998/namespace.html and 21 | 22 | http://www.w3.org/TR/REC-xml for information 23 | about this namespace. 24 |

25 |

26 | Note that local names in this namespace are intended to be 27 | defined only by the World Wide Web Consortium or its subgroups. 28 | The names currently defined in this namespace are listed below. 29 | They should not be used with conflicting semantics by any Working 30 | Group, specification, or document instance. 31 |

32 |

33 | See further below in this document for more information about how to refer to this schema document from your own 35 | XSD schema documents and about the 36 | namespace-versioning policy governing this schema document. 37 |

38 |
39 |
40 |
41 |
42 | 43 | 44 | 45 | 46 |
47 | 48 |

lang (as an attribute name)

49 |

50 | denotes an attribute whose value 51 | is a language code for the natural language of the content of 52 | any element; its value is inherited. This name is reserved 53 | by virtue of its definition in the XML specification.

54 | 55 |
56 |
57 |

Notes

58 |

59 | Attempting to install the relevant ISO 2- and 3-letter 60 | codes as the enumerated possible values is probably never 61 | going to be a realistic possibility. 62 |

63 |

64 | See BCP 47 at 65 | http://www.rfc-editor.org/rfc/bcp/bcp47.txt 66 | and the IANA language subtag registry at 67 | 68 | http://www.iana.org/assignments/language-subtag-registry 69 | for further information. 70 |

71 |

72 | The union allows for the 'un-declaration' of xml:lang with 73 | the empty string. 74 |

75 |
76 |
77 |
78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
88 | 89 | 90 | 91 | 92 |
93 | 94 |

space (as an attribute name)

95 |

96 | denotes an attribute whose 97 | value is a keyword indicating what whitespace processing 98 | discipline is intended for the content of the element; its 99 | value is inherited. This name is reserved by virtue of its 100 | definition in the XML specification.

101 | 102 |
103 |
104 |
105 | 106 | 107 | 108 | 109 | 110 | 111 |
112 | 113 | 114 | 115 |
116 | 117 |

base (as an attribute name)

118 |

119 | denotes an attribute whose value 120 | provides a URI to be used as the base for interpreting any 121 | relative URIs in the scope of the element on which it 122 | appears; its value is inherited. This name is reserved 123 | by virtue of its definition in the XML Base specification.

124 | 125 |

126 | See http://www.w3.org/TR/xmlbase/ 128 | for information about this attribute. 129 |

130 |
131 |
132 |
133 |
134 | 135 | 136 | 137 | 138 |
139 | 140 |

id (as an attribute name)

141 |

142 | denotes an attribute whose value 143 | should be interpreted as if declared to be of type ID. 144 | This name is reserved by virtue of its definition in the 145 | xml:id specification.

146 | 147 |

148 | See http://www.w3.org/TR/xml-id/ 150 | for information about this attribute. 151 |

152 |
153 |
154 |
155 |
156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 |
167 | 168 |

Father (in any context at all)

169 | 170 |
171 |

172 | denotes Jon Bosak, the chair of 173 | the original XML Working Group. This name is reserved by 174 | the following decision of the W3C XML Plenary and 175 | XML Coordination groups: 176 |

177 |
178 |

179 | In appreciation for his vision, leadership and 180 | dedication the W3C XML Plenary on this 10th day of 181 | February, 2000, reserves for Jon Bosak in perpetuity 182 | the XML name "xml:Father". 183 |

184 |
185 |
186 |
187 |
188 |
189 | 190 | 191 | 192 |
193 |

About this schema document

194 | 195 |
196 |

197 | This schema defines attributes and an attribute group suitable 198 | for use by schemas wishing to allow xml:base, 199 | xml:lang, xml:space or 200 | xml:id attributes on elements they define. 201 |

202 |

203 | To enable this, such a schema must import this schema for 204 | the XML namespace, e.g. as follows: 205 |

206 |
207 |           <schema . . .>
208 |            . . .
209 |            <import namespace="http://www.w3.org/XML/1998/namespace"
210 |                       schemaLocation="http://www.w3.org/2001/xml.xsd"/>
211 |      
212 |

213 | or 214 |

215 |
216 |            <import namespace="http://www.w3.org/XML/1998/namespace"
217 |                       schemaLocation="http://www.w3.org/2009/01/xml.xsd"/>
218 |      
219 |

220 | Subsequently, qualified reference to any of the attributes or the 221 | group defined below will have the desired effect, e.g. 222 |

223 |
224 |           <type . . .>
225 |            . . .
226 |            <attributeGroup ref="xml:specialAttrs"/>
227 |      
228 |

229 | will define a type which will schema-validate an instance element 230 | with any of those attributes. 231 |

232 |
233 |
234 |
235 |
236 | 237 | 238 | 239 |
240 |

Versioning policy for this schema document

241 |
242 |

243 | In keeping with the XML Schema WG's standard versioning 244 | policy, this schema document will persist at 245 | 246 | http://www.w3.org/2009/01/xml.xsd. 247 |

248 |

249 | At the date of issue it can also be found at 250 | 251 | http://www.w3.org/2001/xml.xsd. 252 |

253 |

254 | The schema document at that URI may however change in the future, 255 | in order to remain compatible with the latest version of XML 256 | Schema itself, or with the XML namespace itself. In other words, 257 | if the XML Schema or XML namespaces change, the version of this 258 | document at 259 | http://www.w3.org/2001/xml.xsd 260 | 261 | will change accordingly; the version at 262 | 263 | http://www.w3.org/2009/01/xml.xsd 264 | 265 | will not change. 266 |

267 |

268 | Previous dated (and unchanging) versions of this schema 269 | document are at: 270 |

271 | 281 |
282 |
283 |
284 |
285 | 286 |
287 | -------------------------------------------------------------------------------- /onvif/wsdl/xmlmime: -------------------------------------------------------------------------------- 1 | 2 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /onvif/zeep_aiohttp.py: -------------------------------------------------------------------------------- 1 | """AIOHTTP transport for zeep.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from typing import TYPE_CHECKING, Any 8 | 9 | from zeep.cache import SqliteCache 10 | from zeep.transports import Transport 11 | from zeep.utils import get_version 12 | from zeep.wsdl.utils import etree_to_string 13 | 14 | import httpx 15 | from aiohttp import ClientResponse, ClientSession 16 | from requests import Response 17 | 18 | if TYPE_CHECKING: 19 | from lxml.etree import _Element 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class AIOHTTPTransport(Transport): 25 | """Async transport using aiohttp.""" 26 | 27 | def __init__( 28 | self, 29 | session: ClientSession, 30 | verify_ssl: bool = True, 31 | proxy: str | None = None, 32 | cache: SqliteCache | None = None, 33 | ) -> None: 34 | """ 35 | Initialize the transport. 36 | 37 | Args: 38 | session: The aiohttp ClientSession to use (required). The session's 39 | timeout configuration will be used for all requests. 40 | verify_ssl: Whether to verify SSL certificates 41 | proxy: Proxy URL to use 42 | 43 | """ 44 | super().__init__( 45 | cache=cache, 46 | timeout=session.timeout.total, 47 | operation_timeout=session.timeout.sock_read, 48 | ) 49 | 50 | # Override parent's session with aiohttp session 51 | self.session = session 52 | self.verify_ssl = verify_ssl 53 | self.proxy = proxy 54 | self._close_session = False # Never close a provided session 55 | # Extract timeout from session 56 | self._client_timeout = session.timeout 57 | 58 | async def __aenter__(self) -> AIOHTTPTransport: 59 | """Enter async context.""" 60 | return self 61 | 62 | async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: 63 | """Exit async context.""" 64 | 65 | async def aclose(self) -> None: 66 | """Close the transport session.""" 67 | 68 | def _aiohttp_to_httpx_response( 69 | self, aiohttp_response: ClientResponse, content: bytes 70 | ) -> httpx.Response: 71 | """Convert aiohttp ClientResponse to httpx Response.""" 72 | # Create httpx Response with the content 73 | httpx_response = httpx.Response( 74 | status_code=aiohttp_response.status, 75 | headers=httpx.Headers(aiohttp_response.headers), 76 | content=content, 77 | request=httpx.Request( 78 | method=aiohttp_response.method, 79 | url=str(aiohttp_response.url), 80 | ), 81 | ) 82 | 83 | # Add encoding if available 84 | if aiohttp_response.charset: 85 | httpx_response._encoding = aiohttp_response.charset 86 | 87 | # Store cookies if any 88 | if aiohttp_response.cookies: 89 | for name, cookie in aiohttp_response.cookies.items(): 90 | # httpx.Cookies.set only accepts name, value, domain, and path 91 | httpx_response.cookies.set( 92 | name, 93 | cookie.value, 94 | domain=cookie.get("domain") or "", 95 | path=cookie.get("path") or "/", 96 | ) 97 | 98 | return httpx_response 99 | 100 | def _aiohttp_to_requests_response( 101 | self, aiohttp_response: ClientResponse, content: bytes 102 | ) -> Response: 103 | """Convert aiohttp ClientResponse directly to requests Response.""" 104 | new = Response() 105 | new._content = content 106 | new.status_code = aiohttp_response.status 107 | new.headers = dict(aiohttp_response.headers) 108 | # Convert aiohttp cookies to requests format 109 | if aiohttp_response.cookies: 110 | for name, cookie in aiohttp_response.cookies.items(): 111 | new.cookies.set( 112 | name, 113 | cookie.value, 114 | domain=cookie.get("domain"), 115 | path=cookie.get("path"), 116 | ) 117 | new.encoding = aiohttp_response.charset 118 | return new 119 | 120 | async def post( 121 | self, address: str, message: str, headers: dict[str, str] 122 | ) -> httpx.Response: 123 | """ 124 | Perform async POST request. 125 | 126 | Args: 127 | address: The URL to send the request to 128 | message: The message to send 129 | headers: HTTP headers to include 130 | 131 | Returns: 132 | The httpx response object 133 | 134 | """ 135 | return await self._post(address, message, headers) 136 | 137 | async def _post( 138 | self, address: str, message: str, headers: dict[str, str] 139 | ) -> httpx.Response: 140 | """Internal POST implementation.""" 141 | _LOGGER.debug("HTTP Post to %s:\n%s", address, message) 142 | 143 | # Set default headers 144 | headers = headers or {} 145 | headers.setdefault("User-Agent", f"Zeep/{get_version()}") 146 | headers.setdefault("Content-Type", 'text/xml; charset="utf-8"') 147 | 148 | # Handle both str and bytes 149 | if isinstance(message, str): 150 | data = message.encode("utf-8") 151 | else: 152 | data = message 153 | 154 | try: 155 | response = await self.session.post( 156 | address, 157 | data=data, 158 | headers=headers, 159 | proxy=self.proxy, 160 | timeout=self._client_timeout, 161 | ) 162 | 163 | # Read the content to log it before checking status 164 | content = await response.read() 165 | _LOGGER.debug( 166 | "HTTP Response from %s (status: %d):\n%s", 167 | address, 168 | response.status, 169 | content, 170 | ) 171 | 172 | # Convert to httpx Response 173 | return self._aiohttp_to_httpx_response(response, content) 174 | 175 | except TimeoutError as exc: 176 | raise TimeoutError(f"Request to {address} timed out") from exc 177 | 178 | async def post_xml( 179 | self, address: str, envelope: _Element, headers: dict[str, str] 180 | ) -> Response: 181 | """ 182 | Post XML envelope and return parsed response. 183 | 184 | Args: 185 | address: The URL to send the request to 186 | envelope: The XML envelope to send 187 | headers: HTTP headers to include 188 | 189 | Returns: 190 | A Response object compatible with zeep 191 | 192 | """ 193 | message = etree_to_string(envelope) 194 | response = await self.post(address, message, headers) 195 | return self._httpx_to_requests_response(response) 196 | 197 | async def get( 198 | self, 199 | address: str, 200 | params: dict[str, Any] | None = None, 201 | headers: dict[str, str] | None = None, 202 | ) -> Response: 203 | """ 204 | Perform async GET request. 205 | 206 | Args: 207 | address: The URL to send the request to 208 | params: Query parameters 209 | headers: HTTP headers to include 210 | 211 | Returns: 212 | A Response object compatible with zeep 213 | 214 | """ 215 | return await self._get(address, params, headers) 216 | 217 | async def _get( 218 | self, 219 | address: str, 220 | params: dict[str, Any] | None = None, 221 | headers: dict[str, str] | None = None, 222 | ) -> Response: 223 | """Internal GET implementation.""" 224 | _LOGGER.debug("HTTP Get from %s", address) 225 | 226 | # Set default headers 227 | headers = headers or {} 228 | headers.setdefault("User-Agent", f"Zeep/{get_version()}") 229 | 230 | try: 231 | response = await self.session.get( 232 | address, 233 | params=params, 234 | headers=headers, 235 | proxy=self.proxy, 236 | timeout=self._client_timeout, 237 | ) 238 | 239 | # Read content and log before checking status 240 | content = await response.read() 241 | 242 | _LOGGER.debug( 243 | "HTTP Response from %s (status: %d):\n%s", 244 | address, 245 | response.status, 246 | content, 247 | ) 248 | 249 | # Convert directly to requests.Response 250 | return self._aiohttp_to_requests_response(response, content) 251 | 252 | except TimeoutError as exc: 253 | raise TimeoutError(f"Request to {address} timed out") from exc 254 | 255 | def _httpx_to_requests_response(self, response: httpx.Response) -> Response: 256 | """Convert an httpx.Response object to a requests.Response object""" 257 | body = response.read() 258 | 259 | new = Response() 260 | new._content = body 261 | new.status_code = response.status_code 262 | new.headers = response.headers 263 | new.cookies = response.cookies 264 | new.encoding = response.encoding 265 | return new 266 | 267 | def load(self, url: str) -> bytes: 268 | """ 269 | Load content from URL synchronously. 270 | 271 | This method runs the async get method in a new event loop. 272 | 273 | Args: 274 | url: The URL to load 275 | 276 | Returns: 277 | The content as bytes 278 | 279 | """ 280 | # Create a new event loop for sync operation 281 | loop = asyncio.new_event_loop() 282 | try: 283 | response = loop.run_until_complete(self.get(url)) 284 | return response.content 285 | finally: 286 | loop.close() 287 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | # Use a conservative default here; 2 should speed up most setups and not hurt 4 | # any too bad. Override on command line as appropriate. 5 | jobs=2 6 | load-plugins=pylint_strict_informational 7 | persistent=no 8 | extension-pkg-whitelist=ciso8601 9 | 10 | [BASIC] 11 | good-names=id,i,j,k,ex,Run,_,fp,T 12 | 13 | [MESSAGES CONTROL] 14 | # Reasons disabled: 15 | # format - handled by black 16 | # locally-disabled - it spams too much 17 | # duplicate-code - unavoidable 18 | # cyclic-import - doesn't test if both import on load 19 | # abstract-class-little-used - prevents from setting right foundation 20 | # unused-argument - generic callbacks and setup methods create a lot of warnings 21 | # redefined-variable-type - this is Python, we're duck typing! 22 | # too-many-* - are not enforced for the sake of readability 23 | # too-few-* - same as too-many-* 24 | # abstract-method - with intro of async there are always methods missing 25 | # inconsistent-return-statements - doesn't handle raise 26 | # too-many-ancestors - it's too strict. 27 | # wrong-import-order - isort guards this 28 | disable= 29 | format, 30 | abstract-class-little-used, 31 | abstract-method, 32 | cyclic-import, 33 | duplicate-code, 34 | inconsistent-return-statements, 35 | locally-disabled, 36 | not-context-manager, 37 | redefined-variable-type, 38 | too-few-public-methods, 39 | too-many-ancestors, 40 | too-many-arguments, 41 | too-many-branches, 42 | too-many-instance-attributes, 43 | too-many-lines, 44 | too-many-locals, 45 | too-many-public-methods, 46 | too-many-return-statements, 47 | too-many-statements, 48 | too-many-boolean-expressions, 49 | unused-argument, 50 | wrong-import-order, 51 | broad-except, 52 | enable= 53 | use-symbolic-message-instead 54 | 55 | [REPORTS] 56 | score=no 57 | 58 | [TYPECHECK] 59 | # For attrs 60 | ignored-classes=_CountingAttr 61 | 62 | [FORMAT] 63 | expected-line-ending-format=LF 64 | 65 | [EXCEPTIONS] 66 | #overgeneral-exceptions=Exception 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | pythonpath = ["onvif"] 3 | log_cli="true" 4 | log_level="NOTSET" 5 | plugins = ["covdefaults"] 6 | 7 | 8 | [tool.ruff] 9 | target-version = "py310" 10 | 11 | [build-system] 12 | requires = ['setuptools>=65.4.1', 'wheel'] 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Package 2 | aiohttp==3.12.9 3 | ciso8601==2.3.2 4 | httpx==0.28.1 5 | zeep[async]==4.3.1 6 | yarl==1.20.0 7 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | # Package 2 | -r requirements.txt 3 | 4 | # Dev 5 | pytest==8.3.5 6 | pytest-cov==6.1.1 7 | pytest-asyncio==0.26.0 8 | covdefaults==2.3.0 9 | aioresponses==0.7.6 10 | 11 | # pre-commit 12 | pre-commit==4.2.0 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.rst 3 | 4 | [flake8] 5 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build,docs 6 | max-line-length = 88 7 | ignore = 8 | E501, 9 | W503, 10 | E203, 11 | D202, 12 | W504 13 | 14 | [isort] 15 | profile = black 16 | force_sort_within_sections = true 17 | known_first_party = onvif,tests 18 | forced_separate = tests 19 | combine_as_imports = true 20 | 21 | [aliases] 22 | test = pytest 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Package Setup.""" 2 | 3 | import os 4 | 5 | from setuptools import find_packages, setup 6 | 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | version_path = os.path.join(here, "onvif/version.txt") 9 | version = open(version_path).read().strip() 10 | 11 | requires = [ 12 | "aiohttp>=3.12.9", 13 | "httpx>=0.19.0,<1.0.0", 14 | "zeep[async]>=4.2.1,<5.0.0", 15 | "ciso8601>=2.1.3", 16 | "yarl>=1.10.0", 17 | ] 18 | 19 | CLASSIFIERS = [ 20 | "Development Status :: 3 - Alpha", 21 | "Environment :: Console", 22 | "Intended Audience :: Customer Service", 23 | "Intended Audience :: Developers", 24 | "Intended Audience :: Education", 25 | "Intended Audience :: Science/Research", 26 | "Intended Audience :: Telecommunications Industry", 27 | "Natural Language :: English", 28 | "Operating System :: POSIX", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | "Topic :: Multimedia :: Sound/Audio", 31 | "Topic :: Utilities", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Programming Language :: Python :: 3.13", 38 | ] 39 | 40 | setup( 41 | name="onvif-zeep-async", 42 | version=version, 43 | description="Async Python Client for ONVIF Camera", 44 | long_description=open("README.rst").read(), 45 | author="Cherish Chen", 46 | author_email="sinchb128@gmail.com", 47 | maintainer="sinchb", 48 | maintainer_email="sinchb128@gmail.com", 49 | license="MIT", 50 | keywords=["ONVIF", "Camera", "IPC"], 51 | url="http://github.com/hunterjm/python-onvif-zeep-async", 52 | zip_safe=False, 53 | python_requires=">=3.10", 54 | packages=find_packages(exclude=["docs", "examples", "tests"]), 55 | install_requires=requires, 56 | package_data={ 57 | "": ["*.txt", "*.rst"], 58 | "onvif": ["*.wsdl", "*.xsd", "*xml*", "envelope", "include", "addressing"], 59 | "onvif.wsdl": ["*.wsdl", "*.xsd", "*xml*", "envelope", "include", "addressing"], 60 | }, 61 | ) 62 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | # #!/usr/bin/python 2 | # from __future__ import print_function, division 3 | # import unittest 4 | 5 | # from onvif import ONVIFCamera, ONVIFError 6 | 7 | # CAM_HOST = '172.20.9.84' 8 | # CAM_PORT = 80 9 | # CAM_USER = 'root' 10 | # CAM_PASS = 'password' 11 | 12 | # DEBUG = False 13 | 14 | # def log(ret): 15 | # if DEBUG: 16 | # print(ret) 17 | 18 | # class TestDevice(unittest.TestCase): 19 | 20 | # # Class level cam. Run this test more efficiently.. 21 | # cam = ONVIFCamera(CAM_HOST, CAM_PORT, CAM_USER, CAM_PASS) 22 | 23 | # # ***************** Test Capabilities *************************** 24 | # def test_GetWsdlUrl(self): 25 | # ret = self.cam.devicemgmt.GetWsdlUrl() 26 | 27 | # def test_GetServices(self): 28 | # ''' 29 | # Returns a cllection of the devices 30 | # services and possibly their available capabilities 31 | # ''' 32 | # params = {'IncludeCapability': True } 33 | # ret = self.cam.devicemgmt.GetServices(params) 34 | # params = self.cam.devicemgmt.create_type('GetServices') 35 | # params.IncludeCapability=False 36 | # ret = self.cam.devicemgmt.GetServices(params) 37 | 38 | # def test_GetServiceCapabilities(self): 39 | # '''Returns the capabilities of the devce service.''' 40 | # ret = self.cam.devicemgmt.GetServiceCapabilities() 41 | # ret.Network.IPFilter 42 | 43 | # def test_GetCapabilities(self): 44 | # ''' 45 | # Probides a backward compatible interface for the base capabilities. 46 | # ''' 47 | # categorys = ['PTZ', 'Media', 'Imaging', 48 | # 'Device', 'Analytics', 'Events'] 49 | # ret = self.cam.devicemgmt.GetCapabilities() 50 | # for category in categorys: 51 | # ret = self.cam.devicemgmt.GetCapabilities({'Category': category}) 52 | 53 | # with self.assertRaises(ONVIFError): 54 | # self.cam.devicemgmt.GetCapabilities({'Category': 'unknown'}) 55 | 56 | # # *************** Test Network ********************************* 57 | # def test_GetHostname(self): 58 | # ''' Get the hostname from a device ''' 59 | # self.cam.devicemgmt.GetHostname() 60 | 61 | # def test_SetHostname(self): 62 | # ''' 63 | # Set the hostname on a device 64 | # A device shall accept strings formated according to 65 | # RFC 1123 section 2.1 or alternatively to RFC 952, 66 | # other string shall be considered as invalid strings 67 | # ''' 68 | # pre_host_name = self.cam.devicemgmt.GetHostname() 69 | 70 | # self.cam.devicemgmt.SetHostname({'Name':'testHostName'}) 71 | # self.assertEqual(self.cam.devicemgmt.GetHostname().Name, 'testHostName') 72 | 73 | # self.cam.devicemgmt.SetHostname({'Name':pre_host_name.Name}) 74 | 75 | # def test_SetHostnameFromDHCP(self): 76 | # ''' Controls whether the hostname shall be retrieved from DHCP ''' 77 | # ret = self.cam.devicemgmt.SetHostnameFromDHCP(dict(FromDHCP=False)) 78 | # self.assertTrue(isinstance(ret, bool)) 79 | 80 | # def test_GetDNS(self): 81 | # ''' Gets the DNS setting from a device ''' 82 | # ret = self.cam.devicemgmt.GetDNS() 83 | # self.assertTrue(hasattr(ret, 'FromDHCP')) 84 | # if not ret.FromDHCP and len(ret.DNSManual) > 0: 85 | # log(ret.DNSManual[0].Type) 86 | # log(ret.DNSManual[0].IPv4Address) 87 | 88 | # def test_SetDNS(self): 89 | # ''' Set the DNS settings on a device ''' 90 | # ret = self.cam.devicemgmt.SetDNS(dict(FromDHCP=False)) 91 | 92 | # def test_GetNTP(self): 93 | # ''' Get the NTP settings from a device ''' 94 | # ret = self.cam.devicemgmt.GetNTP() 95 | # if ret.FromDHCP == False: 96 | # self.assertTrue(hasattr(ret, 'NTPManual')) 97 | # log(ret.NTPManual) 98 | 99 | # def test_SetNTP(self): 100 | # '''Set the NTP setting''' 101 | # ret = self.cam.devicemgmt.SetNTP(dict(FromDHCP=False)) 102 | 103 | # def test_GetDynamicDNS(self): 104 | # '''Get the dynamic DNS setting''' 105 | # ret = self.cam.devicemgmt.GetDynamicDNS() 106 | # log(ret) 107 | 108 | # def test_SetDynamicDNS(self): 109 | # ''' Set the dynamic DNS settings on a device ''' 110 | # ret = self.cam.devicemgmt.GetDynamicDNS() 111 | # ret = self.cam.devicemgmt.SetDynamicDNS({'Type': 'NoUpdate', 'Name':None, 'TTL':None}) 112 | 113 | # if __name__ == '__main__': 114 | # unittest.main() 115 | -------------------------------------------------------------------------------- /tests/test_snapshot.py: -------------------------------------------------------------------------------- 1 | """Tests for snapshot functionality using aiohttp.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import AsyncGenerator 6 | from contextlib import asynccontextmanager 7 | from unittest.mock import AsyncMock, Mock, patch 8 | 9 | import pytest_asyncio 10 | 11 | import aiohttp 12 | import pytest 13 | from aioresponses import aioresponses 14 | from onvif import ONVIFCamera 15 | from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError 16 | 17 | 18 | @pytest.fixture 19 | def mock_aioresponse(): 20 | """Return aioresponses fixture.""" 21 | # Note: aioresponses will mock all ClientSession instances by default 22 | with aioresponses(passthrough=["http://127.0.0.1:8123"]) as m: 23 | yield m 24 | 25 | 26 | @asynccontextmanager 27 | async def create_test_camera( 28 | host: str = "192.168.1.100", 29 | port: int = 80, 30 | user: str | None = "admin", 31 | passwd: str | None = "password", # noqa: S107 32 | ) -> AsyncGenerator[ONVIFCamera]: 33 | """Create a test camera instance with context manager.""" 34 | cam = ONVIFCamera(host, port, user, passwd) 35 | try: 36 | yield cam 37 | finally: 38 | await cam.close() 39 | 40 | 41 | @pytest_asyncio.fixture 42 | async def camera() -> AsyncGenerator[ONVIFCamera]: 43 | """Create a test camera instance.""" 44 | async with create_test_camera() as cam: 45 | # Mock the device management service to avoid actual WSDL loading 46 | with ( 47 | patch.object(cam, "create_devicemgmt_service", new_callable=AsyncMock), 48 | patch.object( 49 | cam, "create_media_service", new_callable=AsyncMock 50 | ) as mock_media, 51 | ): 52 | # Mock the media service to return snapshot URI 53 | mock_service = Mock() 54 | mock_service.create_type = Mock(return_value=Mock()) 55 | mock_service.GetSnapshotUri = AsyncMock( 56 | return_value=Mock(Uri="http://192.168.1.100/snapshot") 57 | ) 58 | mock_media.return_value = mock_service 59 | yield cam 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_get_snapshot_success_with_digest_auth( 64 | camera: ONVIFCamera, mock_aioresponse: aioresponses 65 | ) -> None: 66 | """Test successful snapshot retrieval with digest authentication.""" 67 | snapshot_data = b"fake_image_data" 68 | 69 | # Mock successful response 70 | mock_aioresponse.get( 71 | "http://192.168.1.100/snapshot", status=200, body=snapshot_data 72 | ) 73 | 74 | # Get snapshot with digest auth (default) 75 | result = await camera.get_snapshot("Profile1", basic_auth=False) 76 | 77 | assert result == snapshot_data 78 | 79 | # Check that the request was made 80 | assert len(mock_aioresponse.requests) == 1 81 | request_key = next(iter(mock_aioresponse.requests.keys())) 82 | assert str(request_key[1]).startswith("http://192.168.1.100/snapshot") 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_get_snapshot_success_with_basic_auth( 87 | camera: ONVIFCamera, mock_aioresponse: aioresponses 88 | ) -> None: 89 | """Test successful snapshot retrieval with basic authentication.""" 90 | snapshot_data = b"fake_image_data" 91 | 92 | # Mock successful response 93 | mock_aioresponse.get( 94 | "http://192.168.1.100/snapshot", status=200, body=snapshot_data 95 | ) 96 | 97 | # Get snapshot with basic auth 98 | result = await camera.get_snapshot("Profile1", basic_auth=True) 99 | 100 | assert result == snapshot_data 101 | 102 | # Check that the request was made 103 | assert len(mock_aioresponse.requests) == 1 104 | request_key = next(iter(mock_aioresponse.requests.keys())) 105 | assert str(request_key[1]).startswith("http://192.168.1.100/snapshot") 106 | 107 | 108 | @pytest.mark.asyncio 109 | async def test_get_snapshot_auth_failure( 110 | camera: ONVIFCamera, mock_aioresponse: aioresponses 111 | ) -> None: 112 | """Test snapshot retrieval with authentication failure.""" 113 | # Mock 401 response 114 | mock_aioresponse.get( 115 | "http://192.168.1.100/snapshot", status=401, body=b"Unauthorized" 116 | ) 117 | 118 | # Should raise ONVIFAuthError 119 | with pytest.raises(ONVIFAuthError) as exc_info: 120 | await camera.get_snapshot("Profile1") 121 | 122 | assert "Failed to authenticate" in str(exc_info.value) 123 | 124 | 125 | @pytest.mark.asyncio 126 | async def test_get_snapshot_with_user_pass_in_url( 127 | camera: ONVIFCamera, mock_aioresponse: aioresponses 128 | ) -> None: 129 | """Test snapshot retrieval when URI contains credentials.""" 130 | # Mock the media service to return URI with credentials 131 | with patch.object( 132 | camera, "create_media_service", new_callable=AsyncMock 133 | ) as mock_media: 134 | mock_service = Mock() 135 | mock_service.create_type = Mock(return_value=Mock()) 136 | mock_service.GetSnapshotUri = AsyncMock( 137 | return_value=Mock(Uri="http://admin:password@192.168.1.100/snapshot") 138 | ) 139 | mock_media.return_value = mock_service 140 | 141 | # First request fails with 401 142 | mock_aioresponse.get( 143 | "http://admin:password@192.168.1.100/snapshot", 144 | status=401, 145 | body=b"Unauthorized", 146 | ) 147 | # Second request succeeds (stripped URL) 148 | mock_aioresponse.get( 149 | "http://192.168.1.100/snapshot", status=200, body=b"image_data" 150 | ) 151 | 152 | result = await camera.get_snapshot("Profile1") 153 | 154 | assert result == b"image_data" 155 | # Should have made 2 requests - first with credentials in URL, second without 156 | request_keys = list(mock_aioresponse.requests.keys()) 157 | assert len(request_keys) == 2 158 | assert str(request_keys[0][1]) == "http://admin:password@192.168.1.100/snapshot" 159 | assert str(request_keys[1][1]) == "http://192.168.1.100/snapshot" 160 | 161 | 162 | @pytest.mark.asyncio 163 | async def test_get_snapshot_timeout( 164 | camera: ONVIFCamera, mock_aioresponse: aioresponses 165 | ) -> None: 166 | """Test snapshot retrieval timeout.""" 167 | # Mock timeout by raising TimeoutError 168 | mock_aioresponse.get( 169 | "http://192.168.1.100/snapshot", exception=TimeoutError("Connection timeout") 170 | ) 171 | 172 | with pytest.raises(ONVIFTimeoutError) as exc_info: 173 | await camera.get_snapshot("Profile1") 174 | 175 | assert "Timed out fetching" in str(exc_info.value) 176 | 177 | 178 | @pytest.mark.asyncio 179 | async def test_get_snapshot_client_error( 180 | camera: ONVIFCamera, mock_aioresponse: aioresponses 181 | ) -> None: 182 | """Test snapshot retrieval with client error.""" 183 | # Mock client error 184 | mock_aioresponse.get( 185 | "http://192.168.1.100/snapshot", 186 | exception=aiohttp.ClientError("Connection failed"), 187 | ) 188 | 189 | with pytest.raises(ONVIFError) as exc_info: 190 | await camera.get_snapshot("Profile1") 191 | 192 | assert "Error fetching" in str(exc_info.value) 193 | 194 | 195 | @pytest.mark.asyncio 196 | async def test_get_snapshot_no_uri_available(camera: ONVIFCamera) -> None: 197 | """Test snapshot when no URI is available.""" 198 | # Mock the media service to raise fault 199 | with patch.object( 200 | camera, "create_media_service", new_callable=AsyncMock 201 | ) as mock_media: 202 | mock_service = Mock() 203 | mock_service.create_type = Mock(return_value=Mock()) 204 | 205 | import zeep.exceptions 206 | 207 | mock_service.GetSnapshotUri = AsyncMock( 208 | side_effect=zeep.exceptions.Fault("Snapshot not supported") 209 | ) 210 | mock_media.return_value = mock_service 211 | 212 | result = await camera.get_snapshot("Profile1") 213 | 214 | assert result is None 215 | 216 | 217 | @pytest.mark.asyncio 218 | async def test_get_snapshot_invalid_uri_response(camera: ONVIFCamera) -> None: 219 | """Test snapshot when device returns invalid URI.""" 220 | # Mock the media service to return invalid response 221 | with patch.object( 222 | camera, "create_media_service", new_callable=AsyncMock 223 | ) as mock_media: 224 | mock_service = Mock() 225 | mock_service.create_type = Mock(return_value=Mock()) 226 | # Return response without Uri attribute 227 | mock_service.GetSnapshotUri = AsyncMock( 228 | return_value=Mock(spec=[]) # No Uri attribute 229 | ) 230 | mock_media.return_value = mock_service 231 | 232 | result = await camera.get_snapshot("Profile1") 233 | 234 | assert result is None 235 | 236 | 237 | @pytest.mark.asyncio 238 | async def test_get_snapshot_404_error( 239 | camera: ONVIFCamera, mock_aioresponse: aioresponses 240 | ) -> None: 241 | """Test snapshot retrieval with 404 error.""" 242 | # Mock 404 response 243 | mock_aioresponse.get("http://192.168.1.100/snapshot", status=404, body=b"Not Found") 244 | 245 | result = await camera.get_snapshot("Profile1") 246 | 247 | # Should return None for non-auth errors 248 | assert result is None 249 | 250 | 251 | @pytest.mark.asyncio 252 | async def test_get_snapshot_uri_caching(camera: ONVIFCamera) -> None: 253 | """Test that snapshot URI is cached after first retrieval.""" 254 | # First call should fetch URI from service 255 | uri = await camera.get_snapshot_uri("Profile1") 256 | assert uri == "http://192.168.1.100/snapshot" 257 | 258 | # Mock the media service to ensure it's not called again 259 | with patch.object( 260 | camera, "create_media_service", new_callable=AsyncMock 261 | ) as mock_media: 262 | mock_media.side_effect = Exception("Should not be called") 263 | 264 | # Second call should use cached URI 265 | uri2 = await camera.get_snapshot_uri("Profile1") 266 | assert uri2 == "http://192.168.1.100/snapshot" 267 | 268 | # Mock media service should not have been called 269 | mock_media.assert_not_called() 270 | 271 | 272 | @pytest.mark.asyncio 273 | async def test_snapshot_client_session_reuse( 274 | camera: ONVIFCamera, mock_aioresponse: aioresponses 275 | ) -> None: 276 | """Test that snapshot client session is reused across requests.""" 277 | snapshot_data = b"fake_image_data" 278 | 279 | # Get reference to the snapshot client 280 | snapshot_client = camera._snapshot_client 281 | 282 | # Mock multiple requests 283 | mock_aioresponse.get( 284 | "http://192.168.1.100/snapshot", status=200, body=snapshot_data 285 | ) 286 | mock_aioresponse.get( 287 | "http://192.168.1.100/snapshot", status=200, body=snapshot_data 288 | ) 289 | 290 | # Make multiple snapshot requests 291 | result1 = await camera.get_snapshot("Profile1") 292 | result2 = await camera.get_snapshot("Profile1") 293 | 294 | assert result1 == snapshot_data 295 | assert result2 == snapshot_data 296 | 297 | # Verify same client session was used 298 | assert camera._snapshot_client is snapshot_client 299 | 300 | 301 | @pytest.mark.asyncio 302 | async def test_get_snapshot_no_credentials(mock_aioresponse: aioresponses) -> None: 303 | """Test snapshot retrieval when camera has no credentials.""" 304 | async with create_test_camera(user=None, passwd=None) as cam: 305 | with ( 306 | patch.object(cam, "create_devicemgmt_service", new_callable=AsyncMock), 307 | patch.object( 308 | cam, "create_media_service", new_callable=AsyncMock 309 | ) as mock_media, 310 | ): 311 | mock_service = Mock() 312 | mock_service.create_type = Mock(return_value=Mock()) 313 | mock_service.GetSnapshotUri = AsyncMock( 314 | return_value=Mock(Uri="http://192.168.1.100/snapshot") 315 | ) 316 | mock_media.return_value = mock_service 317 | 318 | mock_aioresponse.get( 319 | "http://192.168.1.100/snapshot", status=200, body=b"image_data" 320 | ) 321 | 322 | result = await cam.get_snapshot("Profile1") 323 | assert result == b"image_data" 324 | 325 | 326 | @pytest.mark.asyncio 327 | async def test_get_snapshot_with_digest_auth_multiple_requests( 328 | mock_aioresponse: aioresponses, 329 | ) -> None: 330 | """Test that digest auth works correctly across multiple requests.""" 331 | async with create_test_camera() as cam: 332 | with ( 333 | patch.object(cam, "create_devicemgmt_service", new_callable=AsyncMock), 334 | patch.object( 335 | cam, "create_media_service", new_callable=AsyncMock 336 | ) as mock_media, 337 | ): 338 | mock_service = Mock() 339 | mock_service.create_type = Mock(return_value=Mock()) 340 | mock_service.GetSnapshotUri = AsyncMock( 341 | return_value=Mock(Uri="http://192.168.1.100/snapshot") 342 | ) 343 | mock_media.return_value = mock_service 344 | 345 | # Mock multiple successful responses 346 | mock_aioresponse.get( 347 | "http://192.168.1.100/snapshot", status=200, body=b"image1" 348 | ) 349 | mock_aioresponse.get( 350 | "http://192.168.1.100/snapshot", status=200, body=b"image2" 351 | ) 352 | 353 | # Get snapshots with digest auth 354 | result1 = await cam.get_snapshot("Profile1", basic_auth=False) 355 | result2 = await cam.get_snapshot("Profile1", basic_auth=False) 356 | 357 | assert result1 == b"image1" 358 | assert result2 == b"image2" 359 | # Check that 2 requests were made (they're grouped by URL in aioresponses) 360 | request_list = next(iter(mock_aioresponse.requests.values())) 361 | assert len(request_list) == 2 362 | 363 | 364 | @pytest.mark.asyncio 365 | async def test_get_snapshot_mixed_auth_methods(mock_aioresponse: aioresponses) -> None: 366 | """Test switching between basic and digest auth.""" 367 | async with create_test_camera() as cam: 368 | with ( 369 | patch.object(cam, "create_devicemgmt_service", new_callable=AsyncMock), 370 | patch.object( 371 | cam, "create_media_service", new_callable=AsyncMock 372 | ) as mock_media, 373 | ): 374 | mock_service = Mock() 375 | mock_service.create_type = Mock(return_value=Mock()) 376 | mock_service.GetSnapshotUri = AsyncMock( 377 | return_value=Mock(Uri="http://192.168.1.100/snapshot") 378 | ) 379 | mock_media.return_value = mock_service 380 | 381 | # Mock responses 382 | mock_aioresponse.get( 383 | "http://192.168.1.100/snapshot", status=200, body=b"basic_auth_image" 384 | ) 385 | mock_aioresponse.get( 386 | "http://192.168.1.100/snapshot", status=200, body=b"digest_auth_image" 387 | ) 388 | 389 | # Test with basic auth 390 | result1 = await cam.get_snapshot("Profile1", basic_auth=True) 391 | assert result1 == b"basic_auth_image" 392 | 393 | # Test with digest auth 394 | result2 = await cam.get_snapshot("Profile1", basic_auth=False) 395 | assert result2 == b"digest_auth_image" 396 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import pytest 6 | from zeep.loader import parse_xml 7 | import datetime 8 | from onvif.client import ONVIFCamera 9 | from onvif.settings import DEFAULT_SETTINGS 10 | from onvif.transport import ASYNC_TRANSPORT 11 | from onvif.types import FastDateTime, ForgivingTime 12 | 13 | INVALID_TERM_TIME = b'\r\n\r\n\r\nhttp://www.onvif.org/ver10/events/wsdl/PullPointSubscription/PullMessagesResponse\r\n\r\n\r\n\r\n2024-08-17T00:56:16Z\r\n2024-08-17T00:61:16Z\r\n\r\n\r\n\r\n' 14 | _WSDL_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "onvif", "wsdl") 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_parse_invalid_dt(caplog: pytest.LogCaptureFixture) -> None: 19 | device = ONVIFCamera("127.0.0.1", 80, "user", "pass", wsdl_dir=_WSDL_PATH) 20 | device.xaddrs = { 21 | "http://www.onvif.org/ver10/events/wsdl": "http://192.168.210.102:6688/onvif/event_service" 22 | } 23 | # Create subscription manager 24 | subscription = await device.create_notification_service() 25 | operation = subscription.document.bindings[subscription.binding_name].get( 26 | "Subscribe" 27 | ) 28 | envelope = parse_xml( 29 | INVALID_TERM_TIME, # type: ignore[arg-type] 30 | ASYNC_TRANSPORT, 31 | settings=DEFAULT_SETTINGS, 32 | ) 33 | result = operation.process_reply(envelope) 34 | assert result.CurrentTime == datetime.datetime( 35 | 2024, 8, 17, 0, 56, 16, tzinfo=datetime.timezone.utc 36 | ) 37 | assert result.TerminationTime == datetime.datetime( 38 | 2024, 8, 17, 1, 1, 16, tzinfo=datetime.timezone.utc 39 | ) 40 | assert "ValueError" not in caplog.text 41 | 42 | 43 | def test_parse_invalid_datetime() -> None: 44 | with pytest.raises(ValueError, match="Invalid character while parsing year"): 45 | FastDateTime().pythonvalue("aaaa-aa-aaTaa:aa:aaZ") 46 | 47 | 48 | def test_parse_invalid_time() -> None: 49 | with pytest.raises(ValueError, match="Unrecognised ISO 8601 time format"): 50 | ForgivingTime().pythonvalue("aa:aa:aa") 51 | 52 | 53 | def test_fix_datetime_missing_time() -> None: 54 | assert FastDateTime().pythonvalue("2024-08-17") == datetime.datetime( 55 | 2024, 8, 17, 0, 0, 0 56 | ) 57 | 58 | 59 | def test_fix_datetime_missing_t() -> None: 60 | assert FastDateTime().pythonvalue("2024-08-17 00:61:16Z") == datetime.datetime( 61 | 2024, 8, 17, 1, 1, 16, tzinfo=datetime.timezone.utc 62 | ) 63 | assert FastDateTime().pythonvalue("2024-08-17 00:61:16") == datetime.datetime( 64 | 2024, 8, 17, 1, 1, 16 65 | ) 66 | 67 | 68 | def test_fix_datetime_overflow() -> None: 69 | assert FastDateTime().pythonvalue("2024-08-17T00:61:16Z") == datetime.datetime( 70 | 2024, 8, 17, 1, 1, 16, tzinfo=datetime.timezone.utc 71 | ) 72 | assert FastDateTime().pythonvalue("2024-08-17T00:60:16Z") == datetime.datetime( 73 | 2024, 8, 17, 1, 0, 16, tzinfo=datetime.timezone.utc 74 | ) 75 | assert FastDateTime().pythonvalue("2024-08-17T00:59:16Z") == datetime.datetime( 76 | 2024, 8, 17, 0, 59, 16, tzinfo=datetime.timezone.utc 77 | ) 78 | assert FastDateTime().pythonvalue("2024-08-17T23:59:59Z") == datetime.datetime( 79 | 2024, 8, 17, 23, 59, 59, tzinfo=datetime.timezone.utc 80 | ) 81 | assert FastDateTime().pythonvalue("2024-08-17T24:00:00Z") == datetime.datetime( 82 | 2024, 8, 18, 0, 0, 0, tzinfo=datetime.timezone.utc 83 | ) 84 | 85 | 86 | def test_unfixable_datetime_overflow() -> None: 87 | with pytest.raises(ValueError, match="Invalid character while parsing minute"): 88 | FastDateTime().pythonvalue("2024-08-17T999:00:00Z") 89 | 90 | 91 | def test_fix_time_overflow() -> None: 92 | assert ForgivingTime().pythonvalue("24:00:00") == datetime.time(0, 0, 0) 93 | assert ForgivingTime().pythonvalue("23:59:59") == datetime.time(23, 59, 59) 94 | assert ForgivingTime().pythonvalue("23:59:60") == datetime.time(0, 0, 0) 95 | assert ForgivingTime().pythonvalue("23:59:61") == datetime.time(0, 0, 1) 96 | assert ForgivingTime().pythonvalue("23:60:00") == datetime.time(0, 0, 0) 97 | assert ForgivingTime().pythonvalue("23:61:00") == datetime.time(0, 1, 0) 98 | 99 | 100 | def test_unfixable_time_overflow() -> None: 101 | with pytest.raises(ValueError, match="Unrecognised ISO 8601 time format"): 102 | assert ForgivingTime().pythonvalue("999:00:00") 103 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import pytest 6 | from zeep.loader import parse_xml 7 | from onvif.util import strip_user_pass_url, obscure_user_pass_url 8 | 9 | from onvif.client import ONVIFCamera 10 | from onvif.settings import DEFAULT_SETTINGS 11 | from onvif.transport import ASYNC_TRANSPORT 12 | from onvif.util import normalize_url 13 | 14 | PULL_POINT_RESPONSE_MISSING_URL = b'\nurn:uuid:76acd0bc-498e-4657-9414-b386bd4b0985http://192.168.2.18:8080/onvif/device_servicehttp://www.onvif.org/ver10/events/wsdl/EventPortType/CreatePullPointSubscriptionRequest1970-01-01T00:00:00Z1970-01-01T00:00:00Z\r\n' 15 | _WSDL_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "onvif", "wsdl") 16 | 17 | 18 | def test_normalize_url(): 19 | assert normalize_url("http://1.2.3.4:80") == "http://1.2.3.4:80" 20 | assert normalize_url("http://1.2.3.4:80:80") == "http://1.2.3.4:80" 21 | assert normalize_url("http://[dead:beef::1]:80") == "http://[dead:beef::1]:80" 22 | assert normalize_url(None) is None 23 | assert normalize_url(b"http://[dead:beef::1]:80") is None 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_normalize_url_with_missing_url(): 28 | device = ONVIFCamera("127.0.0.1", 80, "user", "pass", wsdl_dir=_WSDL_PATH) 29 | device.xaddrs = { 30 | "http://www.onvif.org/ver10/events/wsdl": "http://192.168.210.102:6688/onvif/event_service" 31 | } 32 | # Create subscription manager 33 | subscription = await device.create_notification_service() 34 | operation = subscription.document.bindings[subscription.binding_name].get( 35 | "Subscribe" 36 | ) 37 | envelope = parse_xml( 38 | PULL_POINT_RESPONSE_MISSING_URL, # type: ignore[arg-type] 39 | ASYNC_TRANSPORT, 40 | settings=DEFAULT_SETTINGS, 41 | ) 42 | result = operation.process_reply(envelope) 43 | assert normalize_url(result.SubscriptionReference.Address._value_1) is None 44 | 45 | 46 | def test_strip_user_pass_url(): 47 | assert strip_user_pass_url("http://1.2.3.4/?user=foo&pass=bar") == "http://1.2.3.4/" 48 | assert strip_user_pass_url("http://1.2.3.4/") == "http://1.2.3.4/" 49 | # Test with userinfo in URL 50 | assert strip_user_pass_url("http://user:pass@1.2.3.4/") == "http://1.2.3.4/" 51 | assert strip_user_pass_url("http://user@1.2.3.4/") == "http://1.2.3.4/" 52 | # Test with both userinfo and query params 53 | assert ( 54 | strip_user_pass_url("http://user:pass@1.2.3.4/?username=foo&password=bar") 55 | == "http://1.2.3.4/" 56 | ) 57 | 58 | 59 | def test_obscure_user_pass_url(): 60 | assert ( 61 | obscure_user_pass_url("http://1.2.3.4/?user=foo&pass=bar") 62 | == "http://1.2.3.4/?user=********&pass=********" 63 | ) 64 | assert obscure_user_pass_url("http://1.2.3.4/") == "http://1.2.3.4/" 65 | # Test with userinfo in URL 66 | assert ( 67 | obscure_user_pass_url("http://user:pass@1.2.3.4/") 68 | == "http://user:********@1.2.3.4/" 69 | ) 70 | assert obscure_user_pass_url("http://user@1.2.3.4/") == "http://********@1.2.3.4/" 71 | # Test with both userinfo and query params 72 | assert ( 73 | obscure_user_pass_url("http://user:pass@1.2.3.4/?username=foo&password=bar") 74 | == "http://user:********@1.2.3.4/?username=********&password=********" 75 | ) 76 | assert ( 77 | obscure_user_pass_url("http://user@1.2.3.4/?password=bar") 78 | == "http://********@1.2.3.4/?password=********" 79 | ) 80 | --------------------------------------------------------------------------------