pax_global_header00006660000000000000000000000064147725621110014520gustar00rootroot0000000000000052 comment=1bbeeb178f3cd69b387b43d1eb359aa2e45d347d async_upnp_client-0.44.0/000077500000000000000000000000001477256211100153225ustar00rootroot00000000000000async_upnp_client-0.44.0/.github/000077500000000000000000000000001477256211100166625ustar00rootroot00000000000000async_upnp_client-0.44.0/.github/dependabot.yml000066400000000000000000000003151477256211100215110ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" async_upnp_client-0.44.0/.github/workflows/000077500000000000000000000000001477256211100207175ustar00rootroot00000000000000async_upnp_client-0.44.0/.github/workflows/ci-cd.yml000066400000000000000000000066671477256211100224400ustar00rootroot00000000000000name: Build on: - push - pull_request env: publish-python-version: 3.12 jobs: lint_test_build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade build python -m pip install --upgrade tox tox-gh-actions - name: Test with tox run: tox - name: Build package run: python -m build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: python-package-distributions-${{ matrix.python-version }} path: dist/ - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 if: ${{ hashFiles('coverage-py312.xml') != '' }} with: files: coverage-py312.xml env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} publish-to-pypi: name: Publish to PyPI runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') needs: - lint_test_build environment: name: pypi url: https://pypi.org/p/async-upnp-client permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions-${{ env.publish-python-version }} path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 github-release: needs: - publish-to-pypi runs-on: ubuntu-latest permissions: contents: write id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions-${{ env.publish-python-version }} path: dist/ - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v3.0.0 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Create GitHub Release env: GITHUB_TOKEN: ${{ github.token }} run: >- gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' --notes "" - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} run: >- gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' publish-to-testpypi: name: Publish to TestPyPI runs-on: ubuntu-latest if: github.repository == 'StevenLooman/async_upnp_client' needs: - lint_test_build environment: name: testpypi url: https://test.pypi.org/p/async-upnp-client permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions-${{ env.publish-python-version }} path: dist/ - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ continue-on-error: true async_upnp_client-0.44.0/.github/workflows/pr_towncrier.yml000066400000000000000000000014001477256211100241520ustar00rootroot00000000000000name: Pull Request requires towncrier file on: - pull_request jobs: pr_require_towncrier_file: runs-on: ubuntu-latest if: github.event.pull_request.user.login != 'dependabot[bot]' steps: - uses: actions/checkout@v4 - name: Ensure towncrier file exists env: PR_NUMBER: ${{ github.event.number }} run: | if [ ! -f "changes/${PR_NUMBER}.feature" ] && [ ! -f "changes/${PR_NUMBER}.bugfix" ] && [ ! -f "changes/${PR_NUMBER}.doc" ] && [ ! -f "changes/${PR_NUMBER}.removal" ] && [ ! -f "changes/${PR_NUMBER}.misc" ]; then echo "Towncrier file for #${PR_NUMBER} not found. Please add a changes file to the `changes/` directory. See README.rst for more information." exit 1 fi async_upnp_client-0.44.0/.gitignore000066400000000000000000000011661477256211100173160ustar00rootroot00000000000000# Visual Studio Code .vscode/* !.vscode/cSpell.json !.vscode/extensions.json !.vscode/tasks.json # IntelliJ IDEA .idea *.iml # pytest .pytest_cache .cache # Packages *.egg *.egg-info dist build eggs .eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 pip-wheel-metadata # Unit test / coverage reports .coverage .tox coverage.xml nosetests.xml htmlcov/ test-reports/ test-results.xml test-output.xml cov.xml # mypy /.mypy_cache/* /.dmypy.json # venv stuff pyvenv.cfg pip-selfcheck.json venv .venv Pipfile* share/* /Scripts/ # GITHUB Proposed Python stuff: *.py[cod] # Other prof/ /.mypy_cache/ coverage-py*.xml async_upnp_client-0.44.0/.pre-commit-config.yaml000066400000000000000000000037421477256211100216110ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v4.6.0' hooks: - id: check-ast - id: check-case-conflict - id: check-symlinks #- id: check-xml - id: debug-statements - id: end-of-file-fixer - id: mixed-line-ending - id: trailing-whitespace exclude: setup.cfg - repo: https://github.com/psf/black rev: '24.8.0' hooks: - id: black args: - --safe - --quiet files: ^(async_upnp_client|tests)/.+\.py$ - repo: https://github.com/codespell-project/codespell rev: 'v2.3.0' hooks: - id: codespell args: - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] files: ^(async_upnp_client|tests)/.+\.py$ - repo: https://github.com/PyCQA/flake8 rev: '7.1.1' hooks: - id: flake8 additional_dependencies: - flake8-docstrings~=1.7.0 - pydocstyle~=6.3.0 files: ^(async_upnp_client|tests)/.+\.py$ - repo: https://github.com/PyCQA/pylint rev: 'v3.3.1' hooks: - id: pylint additional_dependencies: - pytest~=8.3.3 - voluptuous~=0.15.2 - aiohttp>3.9.0,<4.0 - python-didl-lite~=1.4.0 - defusedxml~=0.6.0 - pytest-asyncio >= 0.24,< 0.26 - pytest-aiohttp >=1.0.5,<1.2.0 files: ^(async_upnp_client|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort rev: '5.13.2' hooks: - id: isort args: - --profile=black files: ^(async_upnp_client|tests)/.+\.py$ - repo: https://github.com/pre-commit/mirrors-mypy rev: 'v1.11.2' hooks: - id: mypy args: [--ignore-missing-imports] additional_dependencies: - python-didl-lite~=1.4.0 - pytest~=8.3.3 - aiohttp~=3.10.9 - pytest-asyncio >= 0.24,< 0.26 - pytest-aiohttp >=1.0.5,<1.2.0 files: ^(async_upnp_client|tests)/.+\.py$ async_upnp_client-0.44.0/CHANGES.rst000066400000000000000000000766661477256211100171510ustar00rootroot00000000000000async_upnp_client 0.44.0 (2025-03-31) ===================================== Features -------- - Add the option to change the search-target in SsdpListener. (#262) - Use transport for sending responses instead of a blocking socket. (#263) async_upnp_client 0.43.0 (2025-01-24) ===================================== Features -------- - Send SSDP announcement on server start. In case the announcement cannot be sent, it will cause an error on initialization, instead of at a later moment. This makes handling the error easier from Home Assistant, as the server can be cleaned up directly. (#255) Bugfixes -------- - Fix variable name for WANPPPConnection service. This will add external ipaddress, uptime and wan status for supported devices. (#257) async_upnp_client 0.42.0 (2024-12-22) ===================================== Features -------- - Drop Python 3.8 support. (#245) - Log OSErrors when sending search responses, instead of letting it fail. (#247) Bugfixes -------- - Make async_call_action signature accept string parameter. (#246) async_upnp_client 0.41.0 (2024-10-05) ===================================== Features -------- - Add Python 3.13 support. (#240) - Bump dev dependencies - Bump dependencies Bugfixes -------- - Argument `timeout` of method `aiohttp.ClientSession.request()` has to be of type `ClientTimeout`. (#241) - Fix send_events in server state variable. (#242) - Add proper XML preamble in server communication. (#243) - Fix media_image_url using Resource.url instead of .uri (@chishm) (#244) Misc ---- - #237 async_upnp_client 0.40.0 (2024-07-22) ===================================== Features -------- - Small speed up to verifying keys are present and true in CaseInsensitiveDict (#238) Misc ---- - #239 async_upnp_client 0.39.0 (2024-06-21) ===================================== Features -------- - Only fetch wanted IgdState items in IGD profile. (#227) - Subscribe to IGD to reduce number of polls. This also simplifies the returned IgdState from IgdDevice.async_get_traffic_and_status_data(), as the items from StatusInfo are now diectly added to IgdState. As a bonus, extend the dummy_router to support subscribing and use evented state variables ExternalIPAddress and ConnectionStatus. (#231) - Add pre-/post-hooks when doing/handling HTTP requests. Includes refactoring of Tuples to HttpRequest/HttpResponse, causing in breaking changes. (#233) - Add retrieving of port_mapping_number_of_entries for IGDs. (#234) - Add WANIPv6FirewallControl service to IGD profile. Extend dummy_router as well to support accompanying example scripts. (#235) - Reduce code in ssdp_listener to improve performance (#236) Bugfixes -------- - Fix subscribing to all services, for CLI and profile devices. Fixes only subscribing to top level services, when it should subscribe to services on embedded devices as well. (#230) - Drop unused `--bind` option in `subscribe` command in cli/`upnp-client`. (#232) async_upnp_client 0.38.3 (2024-03-29) ===================================== Features -------- - Try discarding namespaces when in non-strict mode to improve handling of broken devices (#224) Misc ---- - #220 async_upnp_client 0.38.2 (2024-02-12) ===================================== Misc ---- - #216, #217 async_upnp_client 0.38.1 (2024-01-19) ===================================== Bugfixes -------- - Prevent error when album_art_uri is `None` (@darrynlowe) (#215) async_upnp_client 0.38.0 (2023-12-17) ===================================== Misc ---- - #214 async_upnp_client 0.37.0 (2023-12-17) ===================================== Bugfixes -------- - No need to handle None values in IGD add_port_mapping/delete_port_mapping (#208, @JurgenR) (#209) Misc ---- - #204, #210, #211, #212, #213 async_upnp_client 0.36.2 (2023-10-20) ===================================== Bugfixes -------- - Support service WANIPConnection:2 in IGD profile (#206) Misc ---- - #203, #205, #207 async_upnp_client 0.36.1 (2023-09-26) ===================================== Misc ---- - #198, #199, #200 async_upnp_client 0.36.0 (2023-09-25) ===================================== Misc ---- - #197 async_upnp_client 0.35.1 (2023-09-12) ===================================== Features -------- - Server adds a random delay based on MX (@bdraco) (#195) Bugfixes -------- - Use the actual value of the NotificationSubType, instead of leaking the enum name (#179, @ikorb). (#181) Misc ---- - #180, #182, #183, #184, #185, #186, #187, #188, #189, #190, #191, #192, #193, #194, #196 async_upnp_client 0.35.0 (2023-08-27) ===================================== Features -------- - Reduce string conversion in CaseInsensitiveDict lookups (@bdraco) `get` was previously provided by the parent class which had to raise KeyError for missing values. Since try/except is only cheap for the non-exception case the performance was not good when the key was missing. Similar to python/cpython#106665 but in the HA case we call this even more frequently. (#173) - Avoid looking up the local address each packet (@bdraco) The local addr will never change, we can set it when we set the transport. (#174) - Avoid lower-casing already lowercased string (@bdraco) Use the upstr concept (in our case lowerstr) from multidict https://aiohttp-kxepal-test.readthedocs.io/en/latest/multidict.html#upstr (#175) - Reduce memory footprint of CaseInsensitiveDict (@bdraco) (#177) - Avoid fetching time many times to purge devices (@bdraco) Calling SsdpDevice.locations is now a KeysView and no longer has the side effect of purging stale locations. We now use the _timestamp that was injected into the headers to avoid fetching time again. (#178) async_upnp_client 0.34.1 (2023-07-23) ===================================== Features -------- - Add an lru to get_adjusted_url (@bdraco) This function gets called every time we decode an SSDP packet and its usually the same data over and over (#172) async_upnp_client 0.34.0 (2023-06-25) ===================================== Features -------- - Support server event subscription (@PhracturedBlue) (#162) - UpnpServer supports returning plain values from server Actions (@PhracturedBlue) Note that the values are still coerced by its related UpnpStateVariable. (#166) - Server supports deferred SSDP responses via MX header (@PhracturedBlue) (#168) - Support backwards compatible service/device types (@PhracturedBlue) (#169) - Enable servers to define custom routes (@PhracturedBlue) (#170) - Drop Python 3.7 support. (#171) async_upnp_client 0.33.2 (2023-05-21) ===================================== Features -------- - Handle negative values for the bytes/traffic counters in IGDs. Some IGDs implement the counters as i4 (4 byte integer) instead of ui4 (unsigned 4 byte integer). This change tries to work around this by applying an offset of `2**31`. To access the original value, use the variables with a `_original` suffix. (#157) Bugfixes -------- - Now properly send ssdp:byebye when server is stopped. (#158) - Fix indexing bug in cli parsing scope_id in IPv6 target (@senarvi) (#159) Misc ---- - #160, #163, #164, #165 async_upnp_client 0.33.1 (2023-01-30) ===================================== Bugfixes -------- - Don't crash on empty LOCATION header in SSDP message. (#154) async_upnp_client 0.33.0 (2022-12-20) ===================================== Features -------- - Provide sync callbacks too, next to async callbacks. By using sync callbacks, the number of tasks created is reduced. Async callbacks are still supported, though some parameters are renamed to explicitly note the callback is async. Also, the lock in `SsdpDeviceTracker` is removed and thus is no longer a `contextlib.AbstractAsyncContextManager`. (provide_sync_callbacks) Bugfixes -------- - Change comma splitting code in the DLNA module to better handle misbehaving clients. (safer-comma-splitting) async_upnp_client 0.32.3 (2022-12-02) ===================================== Bugfixes -------- - Add support for i8 and ui8 types of UPnP descriptor variables. This fixes parsing of Gerbera's `A_ARG_TYPE_PosSecond` state variable. (@chishm) (int8) Misc ---- - dev_deps: Stricter pinning of development dependencies. async_upnp_client 0.32.2 (2022-11-05) ===================================== Bugfixes -------- - Hostname was always expected to be a valid value when determining IP version. (hostname_unset_fix) - Require scope_id to be set for source and target when creating a ssdp socket. (ipv6_scope_id_unset) Misc ---- - #150 async_upnp_client 0.32.1 (2022-10-23) ===================================== Bugfixes -------- - Be more tolerant about extracting UDN from USN. Before, it was expecting the literal `uuid:`. Now it is case insensitive. (more_tolerant_udn_from_usn_parsing) - Several SSDP related fixes for UPnPServer. (ssdp_fixes) - Fix a race condition in `server.SsdpAdvertisementAnnouncer` regarding protocol initialization. (#148) - Fixes with regard to binding socket(s) for SSDP on macOS. Includes changes/improvements for Linux and Windows as well. (#149) async_upnp_client 0.32.0 (2022-10-10) ===================================== Features -------- - Add ability to build a upnp server. This creates a complete upnp server, including a SSDP search responder and regular SSDP advertisement broadcasting. See the scripts ``contrib/dummy_router.py`` or ``contrib/dummy_tv.py`` for examples. (#143) - Add options to UpnpServer + option to always respond with root device. The option is to ensure that Windows (11) always sees the device in the Network view in the Explorer. (#145) - Provide a single method to retrieve commonly updated data. This contains: * traffic counters: * bytes_received * bytes_sent * packets_received * packets_sent * status_info: * connection_status * last_connection_error * uptime * external_ip_address * derived traffic counters: * kibibytes_per_sec_received (since last call) * kibibytes_per_sec_sent (since last call) * packets_per_sec_received (since last call) * packets_per_sec_sent (since last call) Also let IgdDevice calculate derived traffic counters (value per second). (#146) Bugfixes -------- - * `DmrDevice.async_wait_for_can_play` will poll for changes to the `CurrentTransportActions` state variable, instead of just waiting for events. * `DmrDevice._fetch_headers` will perform a GET with a Range for the first byte, to minimise unnecessary network traffic. (@chishm) (#142) - Breaking change: ``ST`` stands for search target, not service type. (#144) Misc ---- - dev_deps async_upnp_client 0.31.2 (2022-06-19) ===================================== Bugfixes -------- - Cache decoding ssdp packets (@bdraco) (#141) async_upnp_client 0.31.1 (2022-06-06) ===================================== Bugfixes -------- - Ignore the ``HOST``-header in ``SsdpListener``. When a device advertises on both IPv4 and IPV6, the advertisements have the header ``239.255.255.250:1900`` and ``[FF02::C]:1900``, respectively. Given that the ``SsdpListener`` did not ignore this header up to now, it was seen as a change and causing a reinitialisation in the Home Assistant ``upnp`` component. (#140) async_upnp_client 0.31.0 (2022-05-28) ===================================== Bugfixes -------- - Fix errors raised when `AiohttpSessionRequester` is disconnected while writing a request body. The server is allowed to disconnect at any time during a request session, which point we want to retry the request. A disconnection could manifest as an `aiohttp.ServerDisconnectedError` if it happened between requests, or it could be `aiohttp.ClientOSError` if it happened while we are writing the request body. Both errors derive from `aiohttp.ClientConnectionError` for socket errors. Also use `repr` when encapsulating errors for easier debugging. (#139) async_upnp_client 0.30.1 (2022-05-22) ===================================== Bugfixes -------- - Work around aiohttp sending invalid Host-header. When the device url contains a IPv6-addresshost with scope_id, aiohttp sends the scope_id with the Host-header. This causes problems with some devices, returning a HTTP 404 error or perhaps a HTTP 400 error. (#138) async_upnp_client 0.30.0 (2022-05-20) ===================================== Features -------- - Gracefully handle bad Get* state variable actions Some devices don't support all the Get* actions (e.g. GetTransportSettings) that return state variables. This could cause exceptions when trying to poll variables during an (initial) update. Now when an expected (state variable polling) action is missing, or gives a response error, it is logged but no exception is raised. (@chishm) (#137) Misc ---- - #136 async_upnp_client 0.29.0 (2022-04-24) ===================================== Features -------- - Always use CaseInsensitiveDict for headers (@bdraco) Headers were typed to not always be a CaseInsensitiveDict but in practice they always were. By ensuring they are always a CaseInsensitiveDict we can reduce the number of string transforms since we already know when strings have been lowercased. (#135) async_upnp_client 0.28.0 (2022-04-24) ===================================== Features -------- - Optimize location_changed (@bdraco) (#132) - Optimize CaseInsensitiveDict usage (@bdraco) (#133) - Include scope ID in link-local IPv6 host addresses (@chishm) When determining the local IPv6 address used to connect to a remote host, include the scope ID in the returned address string if using a link-local IPv6 address. This is needed to bind event listeners to the correct network interface. (#134) async_upnp_client 0.27.0 (2022-03-17) ===================================== Features -------- - Breaking change: Don't include parts of the library from the ``async_upnp_client`` module. (#126) - Don't raise parse errors if GET request returns an empty file. Added an exception to client_factory.py to handle an empty XML document. If XML document is invalid, scpd_el variable is replaced with a clean ElementTree. (#128) Bugfixes -------- - Don't set Content-Length header but let aiohttp calculate it. This prevents an invalid Content-Length header value when using characters which are encoded to more than one byte. (#129) Misc ---- - bump2version, consolidate_setupcfg, towncrier Pre-towncrier changes ===================== 0.26.0 (2022-03-06) - DLNA DMR profile will pass ``media_url`` unmodified to SetAVTransportURI and SetNextAVTransportURI (@chishm) - Poll DLNA DMR state variables when first connecting (@chishm) - Add CurrentTransportActions to list of state variables to poll when DLNA DMR device is not successfully subscribed (@chishm) - More forgiving parsing of ``Cache-Control`` header value - ``UpnpProfileDevice`` can be used without an ``UpnpEventHandler`` - Store version in ``async_upnp_client.__version__`` 0.25.0 (2022-02-22) - Better handle multi-stack devices by de-duplicating search responses/advertisements from different IP versions in ``SsdpListener`` - Use the parameter ``device_tracker`` to share the ``SsdpDeviceTracker`` between ``SsdpListener``s monitoring the same network - Note that the ``SsdpDeviceTracker`` is now locked by the ``SsdpListener`` in case it is shared. 0.24.0 (2022-02-12) - Add new dummy_tv/dummy_router servers (@StevenLooman) - Drop python 3.6 support, add python 3.10 support (@StevenLooman) - Breaking change: Improve SSDP IPv6 support, for Python versions <3.9, due to missing IPv6Address.scope_id (@StevenLooman) - ``SsdpListener``, ``SsdpAdvertisementListener``, ``SsdpSearchListener``, ``UpnpProfileDevice`` now take ``AddressTupleVXType`` for source and target, instead of IPs - Breaking change: Separate multi-listener event handler functionality from ``UpnpEventHandler`` into ``UpnpEventHandlerRegister`` (@StevenLooman) 0.23.5 (2022-02-06) - Add new dummy_tv/dummy_router servers (@StevenLooman) - Drop python 3.6 support, add python 3.10 support - Ignore devices using link local addresses in their location (@Tigger2014, #119) 0.23.4 (2022-01-16) - Raise ``UpnpXmlContentError`` when device has bad description XML (@chishm, #118) - Raise ``UpnpResponseError`` for HTTP errors in UpnpFactory (@chishm, #118) - Fix ``UpnpXmlParseError`` (@chishm, #118) 0.23.3 (2022-01-03) - ``SsdpListener``: Fix error where a device seen through a search, then byebye-advertisement (@StevenLooman, #117) 0.23.2 (2021-12-22) - Speed up combined_headers in ssdp_listener (@bdraco, #115) - Add handling of broken SSDP-headers (#116) 0.23.1 (2021-12-18) - Bump ``python-didl-lite`` to 1.3.2 - Log missing state vars instead of raising UpnpError in DmrDevice (@chishm) 0.23.0 (2021-11-28) - Allow for renderers that do not provide a list of actions. (@Flameeyes) - Fix parsing of allowedValueList (@StevenLooman) - Add DMS profile for interfacing with DLNA Digital Media Servers (@chishm) - More details reported in Action exceptions (@chishm) - Fix type hints in ``description_cache`` (@epenet, @StevenLooman) 0.22.12 (2021-11-06) - Relax async-timeout dependency, cleanup deprecated sync use (@frenck) 0.22.11 (2021-10-31) - Poll state variables when event subscriptions are rejected (@chishm) 0.22.10 (2021-10-25) - Fix byebye advertisements not propagated because missing location (@chishm) - Require specific services for profile devices (@chishm) - Bump ``python-didl-lite`` to 1.3.1 0.22.9 (2021-10-21) - CLI: Don't crash on upnperrors on upnp-client subscribe (@rytilahti) - DLNA/DMR Profile add support for (@chishm): - play mode (repeat and shuffle) - setting of play_media metadata - SetNextAVTransportURI - setting arbitrary metadata for SetAVTransportURI - playlist title - Ignore Cache-Control headers when comparing for change (@bdraco) - Fix Windows error: ``[WinError 10022] An invalid argument was supplied`` - Fix Windows error: ``[WinError 10049] The requested address is not valid in its context`` 0.22.8 (2021-10-08) - Log when async_http_request retries due to ServerDisconnectedError (@chishm) - More robustness when extracting UDN from USN in ``ssdp.udn_from_headers`` 0.22.7 (2021-10-08) - Ignore devices with an invalid location in ``ssdp_listener.SsdpListener`` - More robustness in IGD profile when parsing StatusInfo - Log warning instead of an error with subscription related problems in profile devices - Ignore devices with a location pointing to localhost in ``ssdp_listener.SsdpListener`` 0.22.6 (2021-10-08) - Bump python-didl-lite to 1.3.0 - More robustness in ``ssdp_listener.SsdpListener`` by requiring a parsed UDN (from USN) and location 0.22.5 (2021-10-03) - More robustness in IGD profile by not relying on keys always being there 0.22.4 (2021-09-28) - DLNA/DMR Profile: Add media metadata properties (@chishm) 0.22.3 (2021-09-27) - Fix race condition where the description is fetched many times (@bdraco) - Retry on ServerDisconnectedError (@bdraco) 0.22.2 (2021-09-27) - Fix DmrDevice._supports method always returning False (@chishm) - More informative exception messages (@chishm) - UpnpProfileDevice unsubscribes from services in parallel (@chishm) 0.22.1 (2021-09-26) - Fix IGD profile - Fix getting all services of root and embedded devices in upnp-client 0.22.0 (2021-09-25) - Always propagate search responses from SsdpListener (@bdraco) - Embedded device support, also fixes the problem where services from embedded devices ended up at the root device 0.21.3 (2021-09-14) - Fix ``ssdp_listener.SsdpDeviceTracker`` to update device's headers upon ssdp:byebye advertisement (@chishm) - Several optimizations related to ``ssdp_listener.SsdpListener`` (@bdraco) 0.21.2 (2021-09-12) - Tweak CaseInsensitiveDict to continue to preserve case (@bdraco) 0.21.1 (2021-09-11) - Log traffic before decoding response text from device - Optimize header comparison (@bdraco) 0.21.0 (2021-09-05) - More pylint/mypy - Fixed NoneType exception in DmrDevice.media_image_url (@mkliche) - Breaking change: Rename ``advertisement.UpnpAdvertisementListener`` to ``advertisement.SsdpAdvertisementListener`` - Breaking change: Rename ``search.SSDPListener`` to ``search.SsdpSearchListener`` - Add ``ssdp_listener.SsdpListener``, class to keep track of devices seen via SSDP advertisements and searches - Breaking change: ``UpnpDevice.boot_id`` and ``UpnpDevice.config_id`` have been moved to ``UpnpDevice.ssdp_headers``, using the respecitive keys from the SSDP headers 0.20.0 (2021-08-17) - Wrap XML ``ParseError`` in an error type derived from it and ``UpnpError`` too (@chishm) - Breaking change: Calling ``async_start`` on ``SSDPListener`` no longer calls ``async_search`` immediately. (#77) @bdraco - Breaking change: The ``target_ip`` argument of ``search.SSDPListener`` has been dropped and replaced with ``target`` which takes a ``AddressTupleVXType`` (#77) @bdraco - Breaking change: The ``target_ip`` argument of ``search.async_search`` has been dropped and replaced with ``target`` which takes a ``AddressTupleVXType`` (#77) @bdraco 0.19.2 (2021-08-04) - Clean up ``UpnpRequester``: Remove ``body_type`` parameter - Allow for overriding the ``target`` in ``ssdp.SSDPListener.async_search()`` - Set SO_BROADCAST flag, fixes ``Permission denied`` error when sending to global broadcast address 0.19.1 (2021-07-21) - Work around duplicate headers in SSDP responses (#74) 0.19.0 (2021-06-19) - Rename ``profiles.dlna.DlanOrgFlags`` to ``DlnaOrgFlags`` to fix a typo (@chishm) - Defer event callback URL determination until event subscriptions are created (@chishm) - Add ``UpnpDevice.icons`` and ``UpnpProfileDevice.icon`` to get URLs to device icons (@chishm) - Add more non-strict parsing of action responses (#68) - Stick with ``asyncio.get_event_loop()`` for Python 3.6 compatibility - asyncio and aiohttp exceptions are wrapped in exceptions derived from ``UpnpError`` to hide implementation details and make catching easier (@chishm) - ``UpnpProfileDevice`` can resubscribe to services automatically, using an asyncio task (@chishm) 0.18.0 (2021-05-23) - Add SSDPListener which is now the underlying code path for async_search and can be used as a long running listener (@bdraco) 0.17.0 (2021-05-09) - Add UpnpFactory non_strict option, replacing disable_state_variable_validation and disable_unknown_out_argument_error - UpnpAction tries non-versioned service type (#68) in non-strict mode - Strip spaces, line endings and null characters before parsing XML (@apal0934) - Properly parse and return subscription timeout - More strip spaces, line engines and null characters before parsing XML 0.16.2 (2021-04-25) - Improve performance of parsing headers by switching to aiohttp.http_parser.HeadersParser (@bdraco) 0.16.1 (2021-04-22) - Don't double-unescape action responses (#50) - Add ``UpnpDevice.service_id()`` to get service by service_id. (@bazwilliams) - Fix 'was never awaited'-warning 0.16.0 (2021-03-30) - Fix timespan formatting for content > 1h - Try to fix invalid device encodings - Rename ``async_upnp_client.traffic`` logger to ``async_upnp_client.traffic.upnp`` and add ``async_upnp_client.traffic.ssdp`` logger - Added ``DeviceUpdater`` to support updating the ``UpnpDevice`` inline on changes to ``BOOTID.UPNP.ORG``/``CONFIGID.UPNP.ORG``/``LOCATION`` - Added support for PAUSED_PLAYBACK state (#56, @brgerig) - Add ``DmrDevice.transport_state``, deprecate ``DmrDevice.state`` - Ignore prefix/namespace in DLNA-Events for better compatibility - DLNA set_transport_uri: Allow supplying own meta_data (e.g. received from a content directory) - DLNA set_transport_uri: Backwards incompatible change: Only media_uri and media_title are required. To override mime_type, upnp_class or dlna_features create meta_data via construct_play_media_metadata() 0.15.0 (2021-03-13) - Added ability to set additional HTTP headers (#51) - Nicer error message on invalid Action Argument - Store raw received argument value (#50) - Be less strict about didl-lite - Allow targeted announces (#53, @elupus) - Support ipv6 search and advertisements (#54, @elupus) 0.14.15 (2020-11-01) - Do not crash on empty XML file (@ekandler) - Option to print timestamp in ISO8601 (@kitlaan) - Option to not print LastChange subscription variable (@kitlaan) - Test with Python 3.8 (@scop) - Less stricter version pinning of ``python-didl-lite`` (@fabaff) - Drop Python 3.5 support, upgrade ``pytest``/``pytest-asyncio`` - Convert type comments to annotations 0.14.14 (2020-04-25) - Add support for fetching the serialNumber (@bdraco) 0.14.13 (2020-04-08) - Expose ``device_type`` on ``UpnpDevice`` and ``UpnpProfileDevice`` 0.14.12 (2019-11-12) - Improve parsing of state variable types: date, dateTime, dateTime.tz, time, time.tz 0.14.11 (2019-09-08) - Support state variable types: date, dateTime, dateTime.tz, time, time.tz 0.14.10 (2019-06-21) - Ability to pass timeout argument to async_search 0.14.9 (2019-05-11) - Fix service resubscription failure: wrong timeout format (@romaincolombo) - Disable transport action checks for non capable devices (@romaincolombo) 0.14.8 (2019-05-04) - Added the disable_unknown_out_argument_error to disable exception raising for not found arguments (@p3g4asus) 0.14.7 (2019-03-29) - Better handle empty default values for state variables (@LooSik) 0.14.6 (2019-03-20) - Fixes to CLI - Handle invalid event-XML containing invalid trailing characters - Improve constructing metadata when playing media on DLNA/DMR devices - Upgrade to python-didl-lite==1.2.4 for namespacing changes 0.14.5 (2019-03-02) - Allow overriding of callback_url in AiohttpNotifyServer (@KarlVogel) - Check action/state_variable exists when retrieving it, preventing an error 0.14.4 (2019-02-04) - Ignore unknown state variable changes via LastChange events 0.14.3 (2019-01-27) - Upgrade to python-didl-lite==1.2.2 for typing info, add ``py.typed`` marker - Add fix for HEOS-1 speakers: default subscription time-out to 9 minutes, only use channel Master (@stp6778) - Upgrade to python-didl-lite==1.2.3 for bugfix 0.14.2 (2019-01-19) - Fix parsing response of Action call without any return values 0.14.1 (2019-01-16) - Fix missing async_upnp_client.profiles in package 0.14.0 (2019-01-14) - Add __repr__ for UpnpAction.Argument and UPnpService.Action (@rytilahti) - Support advertisements and rename discovery to search - Use defusedxml to parse XML (@scop) - Fix UpnpProfileDevice.async_search() + add UpnpProfileDevice.upnp_discover() for backwards compatibility - Add work-around for win32-platform when using ``upnp-client search`` - Minor changes - Typing fixes + automated type checking - Support binding to IP(v4) for search and advertisements 0.13.8 (2018-12-29) - Send content-type/charset on call-action, increasing compatibility (@tsvi) 0.13.7 (2018-12-15) - Make UpnpProfileDevice.device public and add utility methods for device information 0.13.6 (2018-12-10) - Add manufacturer, model_description, model_name, model_number properties to UpnpDevice 0.13.5 (2018-12-09) - Minor refactorings: less private variables which are actually public (through properties) anyway - Store XML-node at UpnpDevice/UpnpService/UpnpAction/UpnpAction.Argument/UpnpStateVariable - Use http.HTTPStatus - Try to be closer to the UPnP spec with regard to eventing 0.13.4 (2018-12-07) - Show a bit more information on unexpected status from HTTP GET - Try to handle invalid XML from LastChange event - Pylint fixes 0.13.3 (2018-11-18) - Add option to ``upnp-client`` to set timeout for device communication/discovery - Add option to be strict (default false) with regard to invalid data - Add more error handling to ``upnp-client`` - Add async_discovery - Fix discovery-traffic not being logged to async_upnp_client.traffic-logger - Add discover devices specific from/for Profile 0.13.2 (2018-11-11) - Better parsing + robustness for media_duration/media_position in dlna-profile - Ensure absolute URL in case a relative URL is returned for DmrDevice.media_image_url (with fix by @rytilahti) - Fix events not being handled when subscribing to all services ('*') - Gracefully handle invalid values from events by setting None/UpnpStateVariable.UPNP_VALUE_ERROR/None as value/value_unchecked - Work-around for devices which don't send the SID upon re-subscribing 0.13.1 (2018-11-03) - Try to subscribe if re-subscribe didn't work + push subscribe-related methods upwards to UpnpProfileDevice - Do store min/max/allowed values at stateVariable even when disable_state_variable_validation has been enabled - Add relative and absolute Seek commands to DLNA DMR profile - Try harder to get a artwork picture for DLNA DMR Profile 0.13.0 (2018-10-27) - Add support for discovery via SSDP - Make IGD aware that certain actions live on WANPPP or WANIPC service 0.12.7 (2018-10-18) - Log cases where a stateVariable has no sendEvents/sendEventsAttribute set at debug level, instead of warning 0.12.6 (2018-10-17) - Handle cases where a stateVariable has no sendEvents/sendEventsAttribute set 0.12.5 (2018-10-13) - Prevent error when not subscribed - upnp-client is more friendly towards user/missing arguments - Debug log spelling fix (@scop) - Add some more IGD methods (@scop) - Add some more IGD WANIPConnection methods (@scop) - Remove new_ prefix from NatRsipStatusInfo fields, fix rsip_available type (@scop) - Add DLNA RC picture controls + refactoring (@scop) - Typing improvements (@scop) - Ignore whitespace around state variable names in XML (@scop) - Add basic printer support (@scop) 0.12.4 (2018-08-17) - Upgrade python-didl-lite to 1.1.0 0.12.3 (2018-08-16) - Install the command line tool via setuptools' console_scripts entrypoint (@mineo) - Show available services/actions when unknown service/action is called - Add configurable timeout to aiohttp requesters - Add IGD device + refactoring common code to async_upnp_client.profile - Minor fixes to CLI, logging, and state_var namespaces 0.12.2 (2018-08-05) - Add TravisCI build - Add AiohttpNotifyServer - More robustness in DmrDevice.media_* - Report service with device UDN 0.12.1 (2018-07-22) - Fix examples/get_volume.py - Fix README.rst - Add aiohttp utility classes 0.12.0 (2018-07-15) - Add upnp-client, move async_upnp_client.async_upnp_client to async_upnp_client.__init__ - Hide voluptuous errors, raise UpnpValueError - Move UPnP eventing to UpnpEventHandler - Do traffic logging in UpnpRequester - Add DLNA DMR implementation/abstraction 0.11.2 (2018-07-05) - Fix log message - Fix typo in case of failed subscription (@yottatsa) 0.11.1 (2018-07-05) - Log getting initial description XMLs with traffic logger as well - Improve SUBSCRIBE and implement SUBSCRIBE-renew - Add more type hints 0.11.0 (2018-07-03) - Add more type hints - Allow ignoring of data validation for state variables, instead of just min/max values 0.10.1 (2018-06-30) - Fixes to setup.py and setup.cfg - Do not crash on empty body on notifications (@rytilahti) - Styling/linting fixes - modelDescription from device description XML is now optional - Move to async/await syntax, from old @asyncio.coroutine/yield from syntax - Allow ignoring of allowedValueRange for state variables - Fix handling of UPnP events and add utils to handle DLNA LastChange events - Do not crash when state variable is not available, allow easier event debugging (@rytilahti) 0.10.0 (2018-05-27) - Remove aiohttp dependency, user is now free/must now provide own UpnpRequester - Don't depend on pytz - Proper (un)escaping of received and sent data in UpnpActions - Add async_upnp_client.traffic logger for easier monitoring of traffic - Support more data types 0.9.1 (2018-04-28) - Support old style ``sendEvents`` - Add response-body when an error is received when calling an action - Fixes to README - Fixes to setup 0.9.0 (2018-03-18) - Initial release async_upnp_client-0.44.0/CONTRIBUTING.rst000066400000000000000000000012321477256211100177610ustar00rootroot00000000000000Async UPnP Client ================= Contributing ------------ If you wish to contribute to ``async_upnp_client``, then thank you! You can create a create a pull request with your changes. Create your pull request(s) against the ``development`` branch. Changes are recorded using `towncrier `_. Please do include change-files in your pull requests. To create a new change-file you can run: $ towncrier create .feature # This creates a change-file for a new feature. # towncrier create .bugfix # This creates a change-file for a bugfix. After creating the file, you can edit the created file. async_upnp_client-0.44.0/LICENSE.txt000066400000000000000000000243601477256211100171520ustar00rootroot00000000000000Apache License ============== _Version 2.0, January 2004_ _<>_ ### Terms and Conditions for use, reproduction, and distribution #### 1. Definitions “License” shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. “Licensor” shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. “Legal Entity” shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, “control” means **(i)** the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the outstanding shares, or **(iii)** beneficial ownership of such entity. “You” (or “Your”) shall mean an individual or Legal Entity exercising permissions granted by this License. “Source” form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. “Object” form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. “Work” shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). “Derivative Works” shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. “Contribution” shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as “Not a Contribution.” “Contributor” shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. #### 2. Grant of Copyright License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. #### 3. Grant of Patent License Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. #### 4. Redistribution You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: * **(a)** You must give any other recipients of the Work or Derivative Works a copy of this License; and * **(b)** You must cause any modified files to carry prominent notices stating that You changed the files; and * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. #### 5. Submission of Contributions Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. #### 6. Trademarks This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. #### 7. Disclaimer of Warranty Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. #### 8. Limitation of Liability In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. #### 9. Accepting Warranty or Additional Liability While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. _END OF TERMS AND CONDITIONS_ ### APPENDIX: How to apply the Apache License to your work To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets `[]` replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same “printed page” as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. async_upnp_client-0.44.0/README.rst000066400000000000000000000267001477256211100170160ustar00rootroot00000000000000Async UPnP Client ================= Asyncio UPnP Client library for Python/asyncio. Written initially for use in `Home Assistant `_ to drive `DLNA DMR`-capable devices, but useful for other projects as well. Status ------ .. image:: https://github.com/StevenLooman/async_upnp_client/workflows/Build/badge.svg :target: https://github.com/StevenLooman/async_upnp_client/actions/workflows/ci-cd.yml .. image:: https://img.shields.io/pypi/v/async_upnp_client.svg :target: https://pypi.python.org/pypi/async_upnp_client .. image:: https://img.shields.io/pypi/format/async_upnp_client.svg :target: https://pypi.python.org/pypi/async_upnp_client .. image:: https://img.shields.io/pypi/pyversions/async_upnp_client.svg :target: https://pypi.python.org/pypi/async_upnp_client .. image:: https://img.shields.io/pypi/l/async_upnp_client.svg :target: https://pypi.python.org/pypi/async_upnp_client General set up -------------- The `UPnP Device Architecture `_ document contains several sections describing different parts of the UPnP standard. These chapters/sections can mostly be mapped to the following modules: * Chapter 1 Discovery * Section 1.1 SSDP: ``async_upnp_client.ssdp`` * Section 1.2 Advertisement: ``async_upnp_client.advertisement`` provides basic functionality to receive advertisements. * Section 1.3 Search: ``async_upnp_client.search`` provides basic functionality to do search requests and gather the responses. * ``async_upnp_client.ssdp_client`` contains the ``SsdpListener`` which combines advertisements and search to get the known devices and provides callbacks on changes. It is meant as something which runs continuously to provide useful information about the SSDP-active devices. * Chapter 2 Description / Chapter 3 Control * ``async_upnp_client.client_factory``/``async_upnp_client.client`` provide a series of classes to get information about the device/services using the 'description', and interact with these devices. * ``async_upnp_client.server`` provides a series of classes to set up a UPnP server, including SSDP discovery/advertisements. * Chapter 4 Eventing * ``async_upnp_client.event_handler`` provides functionality to handle events received from the device. There are several 'profiles' which a device can implement to provide a standard interface to talk to. Some of these profiles are added to this library. The following profiles are currently available: * Internet Gateway Device (IGD) * ``async_upnp_client.profiles.igd`` * Digital Living Network Alliance (DLNA) * ``async_upnp_client.profiles.dlna`` * Printers * ``async_upnp_client.profiles.printer`` For examples on how to use ``async_upnp_client``, see ``examples``/ . Note that this library is most likely does not fully implement all functionality from the UPnP Device Architecture document and/or contains errors/bugs/mis-interpretations. Contributing ------------ See ``CONTRIBUTING.rst``. Development ----------- Development is done on the ``development`` branch. ``pre-commit`` is used to run several checks before committing. You can install ``pre-commit`` and the git-hook by doing:: $ pip install pre-commit $ pre-commit --install The `Open Connectivity Foundation `_ provides a bundle with all `UPnP Specifications `_. Changes ------- Changes are recorded using `Towncier `_. Once a new release is created, towncrier is used to create the file ``CHANGES.rst``. To create a new change run: $ towncrier create . A change type can be one of: - feature: Signifying a new feature. - bugfix: Signifying a bug fix. - doc: Signifying a documentation improvement. - removal: Signifying a deprecation or removal of public API. - misc: A ticket has been closed, but it is not of interest to users. A new file is then created in the ``changes`` directory. Add a short description of the change to that file. Releasing --------- Steps for releasing: - Switch to development: ``git checkout development`` - Do a pull: ``git pull`` - Run towncrier: ``towncrier build --version `` - Commit towncrier results: ``git commit -m "Towncrier"`` - Run bump2version (note that this creates a new commit + tag): ``bump2version --tag major/minor/patch`` - Push to github: ``git push && git push --tags`` Profiling --------- To do profiling it is recommended to install `pytest-profiling `_. Then run a test with profiling enabled, and write the results to a graph:: # Run tests with profiling and svg-output enabled. This will generate prof/*.prof files, and a svg file. $ pytest --profile-svg -k test_case_insensitive_dict_profile ... # Open generated SVG file. $ xdg-open prof/combined.svg Alternatively, you can generate a profiling data file, use `pyprof2calltree `_ to convert the data and open `kcachegrind `_. For example:: # Run tests with profiling enabled, this will generate prof/*.prof files. $ pytest --profile -k test_case_insensitive_dict_profile ... $ pyprof2calltree -i prof/combined.prof -k launching kcachegrind upnp-client ----------- A command line interface is provided via the ``upnp-client`` script. This script can be used to: - call an action - subscribe to services and listen for events - show UPnP traffic (--debug-traffic) from and to the device - show pretty printed JSON (--pprint) for human readability - search for devices - listen for advertisements The output of the script is a single line of JSON for each action-call or subscription-event. See the programs help for more information. An example of calling an action:: $ upnp-client --pprint call-action http://192.168.178.10:49152/description.xml RC/GetVolume InstanceID=0 Channel=Master { "timestamp": 1531482271.5603056, "service_id": "urn:upnp-org:serviceId:RenderingControl", "service_type": "urn:schemas-upnp-org:service:RenderingControl:1", "action": "GetVolume", "in_parameters": { "InstanceID": 0, "Channel": "Master" }, "out_parameters": { "CurrentVolume": 70 } } An example of subscribing to all services, note that the program stays running until you stop it (ctrl-c):: $ upnp-client --pprint subscribe http://192.168.178.10:49152/description.xml \* { "timestamp": 1531482518.3663802, "service_id": "urn:upnp-org:serviceId:RenderingControl", "service_type": "urn:schemas-upnp-org:service:RenderingControl:1", "state_variables": { "LastChange": "\n\n\n\n\n\n" } } { "timestamp": 1531482518.366804, "service_id": "urn:upnp-org:serviceId:RenderingControl", "service_type": "urn:schemas-upnp-org:service:RenderingControl:1", "state_variables": { "Mute": false, "Volume": 70 } } ... You can subscribe to list of services by providing these names or abbreviated names, such as:: $ upnp-client --pprint subscribe http://192.168.178.10:49152/description.xml RC AVTransport An example of searching for devices:: $ upnp-client --pprint search { "Cache-Control": "max-age=3600", "Date": "Sat, 27 Oct 2018 10:43:42 GMT", "EXT": "", "Location": "http://192.168.178.1:49152/description.xml", "OPT": "\"http://schemas.upnp.org/upnp/1/0/\"; ns=01", "01-NLS": "906ad736-cfc4-11e8-9c22-8bb67c653324", "Server": "Linux/4.14.26+, UPnP/1.0, Portable SDK for UPnP devices/1.6.20.jfd5", "X-User-Agent": "redsonic", "ST": "upnp:rootdevice", "USN": "uuid:e3a17dd5-9d85-3131-3c34-b827eb498d72::upnp:rootdevice", "_timestamp": "2018-10-27 12:43:09.125408", "_host": "192.168.178.1", "_port": 49152 "_udn": "uuid:e3a17dd5-9d85-3131-3c34-b827eb498d72", "_source": "search" } An example of listening for advertisements, note that the program stays running until you stop it (ctrl-c):: $ upnp-client --pprint advertisements { "Host": "239.255.255.250:1900", "Cache-Control": "max-age=30", "Location": "http://192.168.178.1:1900/WFADevice.xml", "NTS": "ssdp:alive", "Server": "POSIX, UPnP/1.0 UPnP Stack/2013.4.3.0", "NT": "urn:schemas-wifialliance-org:device:WFADevice:1", "USN": "uuid:99cb221c-1f15-c620-dc29-395f415623c6::urn:schemas-wifialliance-org:device:WFADevice:1", "_timestamp": "2018-12-23 11:22:47.154293", "_host": "192.168.178.1", "_port": 1900 "_udn": "uuid:99cb221c-1f15-c620-dc29-395f415623c6", "_source": "advertisement" } IPv6 support ------------ IPv6 is supported for the UPnP client functionality as well as the SSDP functionality. Please do note that multicast over IPv6 does require a ``scope_id``/interface ID. The ``scope_id`` is used to specify which interface should be used. There are several ways to get the ``scope_id``. Via Python this can be done via the `ifaddr `_ library. From the (Linux) command line the ``scope_id`` can be found via the `ip` command:: $ ip address ... 6: eth0: mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:15:5d:38:97:cf brd ff:ff:ff:ff:ff:ff inet 192.168.1.2/24 brd 192.168.1.255 scope global eth0 valid_lft forever preferred_lft forever inet6 fe80::215:5dff:fe38:97cf/64 scope link valid_lft forever preferred_lft forever In this case, the interface index is 6 (start of the line) and thus the ``scope_id`` is ``6``. Or on Windows using the ``ipconfig`` command:: C:\> ipconfig /all ... Ethernet adapter Ethernet: ... Link-local IPv6 Address . . . . . : fe80::e530:c739:24d7:c8c7%8(Preferred) ... The ``scope_id`` is ``8`` in this example, as shown after the ``%`` character at the end of the IPv6 address. Or on macOS using the ``ifconfig`` command:: % ifconfig ... en0: flags=8863 mtu 1500 options=50b ether 38:c9:86:30:fe:be inet6 fe80::215:5dff:fe38:97cf%en0 prefixlen 64 secured scopeid 0x4 ... The ``scope_id`` is ``4`` in this example, as shown by ``scopeid 0x4``. Note that this is a hexadecimal value. Be aware that Python ``<3.9`` does not support the ``IPv6Address.scope_id`` attribute. As such, a ``AddressTupleVXType`` is used to specify the ``source``- and ``target``-addresses. In case of IPv4, ``AddressTupleV4Type`` is a 2-tuple with ``address``, ``port``. ``AddressTupleV6Type`` is used for IPv6 and is a 4-tuple with ``address``, ``port``, ``flowinfo``, ``scope_id``. More information can be found in the Python ``socket`` module documentation. All functionality regarding SSDP uses ``AddressTupleVXType`` the specify addresses. For consistency, the ``AiohttpNotifyServer`` also uses a tuple the specify the ``source`` (the address and port the notify server listens on.) async_upnp_client-0.44.0/async_upnp_client/000077500000000000000000000000001477256211100210375ustar00rootroot00000000000000async_upnp_client-0.44.0/async_upnp_client/__init__.py000066400000000000000000000001201477256211100231410ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client module.""" __version__ = "0.44.0" async_upnp_client-0.44.0/async_upnp_client/advertisement.py000066400000000000000000000122401477256211100242620ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.advertisement module.""" import asyncio import logging import socket from asyncio.events import AbstractEventLoop from asyncio.transports import BaseTransport, DatagramTransport from typing import Any, Callable, Coroutine, Optional from async_upnp_client.const import AddressTupleVXType, NotificationSubType, SsdpSource from async_upnp_client.ssdp import ( SSDP_DISCOVER, SsdpProtocol, determine_source_target, get_ssdp_socket, ) from async_upnp_client.utils import CaseInsensitiveDict _LOGGER = logging.getLogger(__name__) class SsdpAdvertisementListener: """SSDP Advertisement listener.""" # pylint: disable=too-many-instance-attributes def __init__( self, async_on_alive: Optional[ Callable[[CaseInsensitiveDict], Coroutine[Any, Any, None]] ] = None, async_on_byebye: Optional[ Callable[[CaseInsensitiveDict], Coroutine[Any, Any, None]] ] = None, async_on_update: Optional[ Callable[[CaseInsensitiveDict], Coroutine[Any, Any, None]] ] = None, on_alive: Optional[Callable[[CaseInsensitiveDict], None]] = None, on_byebye: Optional[Callable[[CaseInsensitiveDict], None]] = None, on_update: Optional[Callable[[CaseInsensitiveDict], None]] = None, source: Optional[AddressTupleVXType] = None, target: Optional[AddressTupleVXType] = None, loop: Optional[AbstractEventLoop] = None, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments,too-many-positional-arguments assert ( async_on_alive or async_on_byebye or async_on_update or on_alive or on_byebye or on_update ), "Provide at least one callback" self.async_on_alive = async_on_alive self.async_on_byebye = async_on_byebye self.async_on_update = async_on_update self.on_alive = on_alive self.on_byebye = on_byebye self.on_update = on_update self.source, self.target = determine_source_target(source, target) self.loop: AbstractEventLoop = loop or asyncio.get_event_loop() self._transport: Optional[BaseTransport] = None def _on_data(self, request_line: str, headers: CaseInsensitiveDict) -> None: """Handle data.""" if headers.get_lower("man") == SSDP_DISCOVER: # Ignore discover packets. return notification_sub_type = headers.get_lower("nts") if notification_sub_type is None: _LOGGER.debug("Got non-advertisement packet: %s, %s", request_line, headers) return if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Received advertisement, _remote_addr: %s, NT: %s, NTS: %s, USN: %s, location: %s", headers.get_lower("_remote_addr", ""), headers.get_lower("nt", ""), headers.get_lower("nts", ""), headers.get_lower("usn", ""), headers.get_lower("location", ""), ) headers["_source"] = SsdpSource.ADVERTISEMENT if notification_sub_type == NotificationSubType.SSDP_ALIVE: if self.async_on_alive: coro = self.async_on_alive(headers) self.loop.create_task(coro) if self.on_alive: self.on_alive(headers) elif notification_sub_type == NotificationSubType.SSDP_BYEBYE: if self.async_on_byebye: coro = self.async_on_byebye(headers) self.loop.create_task(coro) if self.on_byebye: self.on_byebye(headers) elif notification_sub_type == NotificationSubType.SSDP_UPDATE: if self.async_on_update: coro = self.async_on_update(headers) self.loop.create_task(coro) if self.on_update: self.on_update(headers) def _on_connect(self, transport: DatagramTransport) -> None: sock: Optional[socket.socket] = transport.get_extra_info("socket") _LOGGER.debug("On connect, transport: %s, socket: %s", transport, sock) self._transport = transport async def async_start(self) -> None: """Start listening for advertisements.""" _LOGGER.debug("Start listening for advertisements") # Construct a socket for use with this pairs of endpoints. sock, _source, _target = get_ssdp_socket(self.source, self.target) # Bind to address. address = ("", self.target[1]) _LOGGER.debug("Binding socket, socket: %s, address: %s", sock, address) sock.bind(address) # Create protocol and send discovery packet. await self.loop.create_datagram_endpoint( lambda: SsdpProtocol( self.loop, on_connect=self._on_connect, on_data=self._on_data, ), sock=sock, ) async def async_stop(self) -> None: """Stop listening for advertisements.""" _LOGGER.debug("Stop listening for advertisements") if self._transport: self._transport.close() async_upnp_client-0.44.0/async_upnp_client/aiohttp.py000066400000000000000000000306671477256211100230750ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.aiohttp module.""" import asyncio import logging from asyncio.events import AbstractEventLoop, AbstractServer from ipaddress import ip_address from typing import Dict, Mapping, Optional from urllib.parse import urlparse import aiohttp.web from aiohttp import ( ClientConnectionError, ClientError, ClientResponseError, ClientSession, ClientTimeout, ) from async_upnp_client.client import UpnpRequester from async_upnp_client.const import ( AddressTupleVXType, HttpRequest, HttpResponse, IPvXAddress, ) from async_upnp_client.event_handler import UpnpEventHandler, UpnpNotifyServer from async_upnp_client.exceptions import ( UpnpClientResponseError, UpnpCommunicationError, UpnpConnectionError, UpnpConnectionTimeoutError, UpnpServerOSError, ) _LOGGER = logging.getLogger(__name__) _LOGGER_TRAFFIC_UPNP = logging.getLogger("async_upnp_client.traffic.upnp") def _fixed_host_header(url: str) -> Dict[str, str]: """Strip scope_id from IPv6 host, if needed.""" if "%" not in url: return {} url_parts = urlparse(url) if url_parts.hostname and "%" in url_parts.hostname: idx = url_parts.hostname.rindex("%") fixed_hostname = url_parts.hostname[:idx] if ":" in fixed_hostname: fixed_hostname = f"[{fixed_hostname}]" host = ( f"{fixed_hostname}:{url_parts.port}" if url_parts.port else fixed_hostname ) return {"Host": host} return {} class AiohttpRequester(UpnpRequester): """Standard AioHttpUpnpRequester, to be used with UpnpFactory.""" # pylint: disable=too-few-public-methods def __init__( self, timeout: int = 5, http_headers: Optional[Mapping[str, str]] = None ) -> None: """Initialize.""" self._timeout = ClientTimeout(total=float(timeout)) self._http_headers = http_headers or {} async def async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request.""" req_headers = { **_fixed_host_header(http_request.url), **self._http_headers, **(http_request.headers or {}), } log_traffic = _LOGGER_TRAFFIC_UPNP.isEnabledFor(logging.DEBUG) if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Sending request:\n%s %s\n%s\n%s\n", http_request.method, http_request.url, "\n".join( [key + ": " + value for key, value in (req_headers or {}).items()] ), http_request.body or "", ) try: async with ClientSession() as session: async with session.request( http_request.method, http_request.url, headers=req_headers, data=http_request.body, timeout=self._timeout, ) as response: status = response.status resp_headers: Mapping = response.headers or {} resp_body = await response.read() if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Got response from %s %s:\n%s\n%s\n\n%s", http_request.method, http_request.url, status, "\n".join( [ key + ": " + value for key, value in resp_headers.items() ] ), resp_body, ) resp_body_text = await response.text() except asyncio.TimeoutError as err: raise UpnpConnectionTimeoutError(repr(err)) from err except ClientConnectionError as err: raise UpnpConnectionError(repr(err)) from err except ClientResponseError as err: raise UpnpClientResponseError( request_info=err.request_info, history=err.history, status=err.status, message=err.message, headers=err.headers, ) from err except ClientError as err: raise UpnpCommunicationError(repr(err)) from err except UnicodeDecodeError as err: raise UpnpCommunicationError(repr(err)) from err return HttpResponse(status, resp_headers, resp_body_text) class AiohttpSessionRequester(UpnpRequester): """ Standard AiohttpSessionRequester, to be used with UpnpFactory. With pluggable session. """ # pylint: disable=too-few-public-methods def __init__( self, session: ClientSession, with_sleep: bool = False, timeout: int = 5, http_headers: Optional[Mapping[str, str]] = None, ) -> None: """Initialize.""" self._session = session self._with_sleep = with_sleep self._timeout = ClientTimeout(total=float(timeout)) self._http_headers = http_headers or {} async def async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request with a retry on ServerDisconnectedError. The HTTP/1.1 spec allows the server to disconnect at any time. We want to retry the request in this event. """ for _ in range(2): try: return await self._async_http_request(http_request) except ClientConnectionError as err: _LOGGER.debug( "%r during request %s %s; retrying", err, http_request.method, http_request.url, ) try: return await self._async_http_request(http_request) except ClientConnectionError as err: raise UpnpConnectionError(repr(err)) from err async def _async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request.""" # pylint: disable=too-many-arguments req_headers = { **_fixed_host_header(http_request.url), **self._http_headers, **(http_request.headers or {}), } log_traffic = _LOGGER_TRAFFIC_UPNP.isEnabledFor(logging.DEBUG) if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Sending request:\n%s %s\n%s\n%s\n", http_request.method, http_request.url, "\n".join( [key + ": " + value for key, value in (req_headers or {}).items()] ), http_request.body or "", ) if self._with_sleep: await asyncio.sleep(0) try: async with self._session.request( http_request.method, http_request.url, headers=req_headers, data=http_request.body, timeout=self._timeout, ) as response: status = response.status resp_headers: Mapping = response.headers or {} resp_body = await response.read() if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Got response from %s %s:\n%s\n%s\n\n%s", http_request.method, http_request.url, status, "\n".join( [key + ": " + value for key, value in resp_headers.items()] ), resp_body, ) resp_body_text = await response.text() except asyncio.TimeoutError as err: raise UpnpConnectionTimeoutError(repr(err)) from err except ClientConnectionError: raise except ClientResponseError as err: raise UpnpClientResponseError( request_info=err.request_info, history=err.history, status=err.status, message=err.message, headers=err.headers, ) from err except ClientError as err: raise UpnpCommunicationError(repr(err)) from err except UnicodeDecodeError as err: raise UpnpCommunicationError(repr(err)) from err return HttpResponse(status, resp_headers, resp_body_text) class AiohttpNotifyServer(UpnpNotifyServer): """ Aio HTTP Server to handle incoming events. It is advisable to use one AiohttpNotifyServer per listening IP, UpnpDevices can share a AiohttpNotifyServer/UpnpEventHandler. """ def __init__( self, requester: UpnpRequester, source: AddressTupleVXType, callback_url: Optional[str] = None, loop: Optional[AbstractEventLoop] = None, ) -> None: """Initialize.""" self._source = source self._callback_url = callback_url self._loop = loop or asyncio.get_event_loop() self._aiohttp_server: Optional[aiohttp.web.Server] = None self._server: Optional[AbstractServer] = None self.event_handler = UpnpEventHandler(self, requester) async def async_start_server(self) -> None: """Start the HTTP server.""" self._aiohttp_server = aiohttp.web.Server(self._handle_request) try: self._server = await self._loop.create_server( self._aiohttp_server, self._source[0], self._source[1] ) except OSError as err: _LOGGER.error( "Failed to create HTTP server at %s:%d: %s", self._source[0], self._source[1], err, ) raise UpnpServerOSError( errno=err.errno, strerror=err.strerror, ) from err # Get listening port. socks = self._server.sockets assert socks and len(socks) == 1 sock = socks[0] self._source = sock.getsockname() _LOGGER.debug("New source for UpnpNotifyServer: %s", self._source) async def async_stop_server(self) -> None: """Stop the HTTP server.""" await self.event_handler.async_unsubscribe_all() if self._aiohttp_server: await self._aiohttp_server.shutdown(10) self._aiohttp_server = None if self._server: self._server.close() self._server = None async def _handle_request( self, request: aiohttp.web.BaseRequest ) -> aiohttp.web.Response: """Handle incoming requests.""" _LOGGER.debug("Received request: %s", request) log_traffic = _LOGGER_TRAFFIC_UPNP.isEnabledFor(logging.DEBUG) headers = request.headers body = await request.text() if log_traffic: _LOGGER_TRAFFIC_UPNP.debug( "Incoming request:\nNOTIFY\n%s\n\n%s", "\n".join([key + ": " + value for key, value in headers.items()]), body, ) if request.method != "NOTIFY": _LOGGER.debug("Not notify") return aiohttp.web.Response(status=405) http_request = HttpRequest( request.method, self.callback_url, request.headers, body ) status = await self.event_handler.handle_notify(http_request) _LOGGER.debug("NOTIFY response status: %s", status) if log_traffic: _LOGGER_TRAFFIC_UPNP.debug("Sending response: %s", status) return aiohttp.web.Response(status=status) @property def listen_ip(self) -> IPvXAddress: """Get listening IP Address.""" return ip_address(self._source[0]) @property def listen_host(self) -> str: """Get listening host.""" return str(self.listen_ip) @property def listen_port(self) -> int: """Get the listening port.""" return self._source[1] @property def callback_url(self) -> str: """Return callback URL on which we are callable.""" listen_ip = self.listen_ip return self._callback_url or ( self._callback_url or f"http://{self.listen_host}:{self.listen_port}/notify" if listen_ip.version == 4 else f"http://[{self.listen_host}]:{self.listen_port}/notify" ) async_upnp_client-0.44.0/async_upnp_client/cli.py000066400000000000000000000341241477256211100221640ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.cli module.""" # pylint: disable=invalid-name import argparse import asyncio import json import logging import operator import sys import time from datetime import datetime from typing import Any, Optional, Sequence, Tuple, Union, cast from async_upnp_client.advertisement import SsdpAdvertisementListener from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpRequester from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import AddressTupleVXType from async_upnp_client.exceptions import UpnpResponseError from async_upnp_client.profiles.dlna import dlna_handle_notify_last_change from async_upnp_client.search import async_search as async_ssdp_search from async_upnp_client.ssdp import SSDP_IP_V4, SSDP_IP_V6, SSDP_PORT, SSDP_ST_ALL from async_upnp_client.utils import CaseInsensitiveDict, get_local_ip logging.basicConfig() _LOGGER = logging.getLogger("upnp-client") _LOGGER.setLevel(logging.ERROR) _LOGGER_LIB = logging.getLogger("async_upnp_client") _LOGGER_LIB.setLevel(logging.ERROR) _LOGGER_TRAFFIC = logging.getLogger("async_upnp_client.traffic") _LOGGER_TRAFFIC.setLevel(logging.ERROR) parser = argparse.ArgumentParser(description="upnp_client") parser.add_argument("--debug", action="store_true", help="Show debug messages") parser.add_argument("--debug-traffic", action="store_true", help="Show network traffic") parser.add_argument( "--pprint", action="store_true", help="Pretty-print (indent) JSON output" ) parser.add_argument("--timeout", type=int, help="Timeout for connection", default=4) parser.add_argument( "--strict", action="store_true", help="Be strict about invalid data received" ) parser.add_argument( "--iso8601", action="store_true", help="Print timestamp in ISO8601 format" ) subparsers = parser.add_subparsers(title="Command", dest="command") subparsers.required = True subparser = subparsers.add_parser("call-action", help="Call an action") subparser.add_argument("device", help="URL to device description XML") subparser.add_argument( "call-action", nargs="+", help="service/action param1=val1 param2=val2" ) subparser = subparsers.add_parser("subscribe", help="Subscribe to services") subparser.add_argument("device", help="URL to device description XML") subparser.add_argument( "service", nargs="+", help="service type or part or abbreviation" ) subparser.add_argument( "--nolastchange", action="store_true", help="Do not show LastChange events" ) subparser = subparsers.add_parser("search", help="Search for devices") subparser.add_argument("--bind", help="ip to bind to, e.g., 192.168.0.10") subparser.add_argument( "--target", help="target ip, e.g., 192.168.0.10 or FF02::C%%6 to request from", ) subparser.add_argument( "--target_port", help="port, e.g., 1900 or 1892 to request from", default=SSDP_PORT, type=int, ) subparser.add_argument( "--search_target", help="search target to search for", default=SSDP_ST_ALL, ) subparser = subparsers.add_parser("advertisements", help="Listen for advertisements") subparser.add_argument( "--bind", help="ip to bind to, e.g., 192.168.0.10", ) subparser.add_argument( "--target", help="target ip, e.g., 239.255.255.250 or FF02::C to listen to", ) subparser.add_argument( "--target_port", help="port, e.g., 1900 or 1892 to request from", default=SSDP_PORT, type=int, ) args = parser.parse_args() pprint_indent = 4 if args.pprint else None event_handler = None async def create_device(description_url: str) -> UpnpDevice: """Create UpnpDevice.""" timeout = args.timeout requester = AiohttpRequester(timeout) non_strict = not args.strict factory = UpnpFactory(requester, non_strict=non_strict) return await factory.async_create_device(description_url) def get_timestamp() -> Union[str, float]: """Timestamp depending on configuration.""" if args.iso8601: return datetime.now().isoformat(" ") return time.time() def service_from_device(device: UpnpDevice, service_name: str) -> Optional[UpnpService]: """Get UpnpService from UpnpDevice by name or part or abbreviation.""" for service in device.all_services: part = service.service_id.split(":")[-1] abbr = "".join([c for c in part if c.isupper()]) if service_name in (service.service_type, part, abbr): return service return None def on_event( service: UpnpService, service_variables: Sequence[UpnpStateVariable] ) -> None: """Handle a UPnP event.""" _LOGGER.debug( "State variable change for %s, variables: %s", service, ",".join([sv.name for sv in service_variables]), ) obj = { "timestamp": get_timestamp(), "service_id": service.service_id, "service_type": service.service_type, "state_variables": {sv.name: sv.value for sv in service_variables}, } # special handling for DLNA LastChange state variable if len(service_variables) == 1 and service_variables[0].name == "LastChange": if not args.nolastchange: print(json.dumps(obj, indent=pprint_indent)) last_change = service_variables[0] dlna_handle_notify_last_change(last_change) else: print(json.dumps(obj, indent=pprint_indent)) async def call_action(description_url: str, call_action_args: Sequence) -> None: """Call an action and show results.""" # pylint: disable=too-many-locals device = await create_device(description_url) if "/" in call_action_args[0]: service_name, action_name = call_action_args[0].split("/") else: service_name = call_action_args[0] action_name = "" for action_arg in call_action_args[1:]: if "=" not in action_arg: print(f"Invalid argument value: {action_arg}") print("Use: Argument=value") sys.exit(1) action_args = {a.split("=", 1)[0]: a.split("=", 1)[1] for a in call_action_args[1:]} # get service service = service_from_device(device, service_name) if not service: services_str = "\n".join( [ " " + device_service.service_id.split(":")[-1] for device_service in device.all_services ] ) print(f"Unknown service: {service_name}") print(f"Available services:\n{services_str}") sys.exit(1) # get action if not service.has_action(action_name): actions_str = "\n".join([f" {name}" for name in sorted(service.actions)]) print(f"Unknown action: {action_name}") print(f"Available actions:\n{actions_str}") sys.exit(1) action = service.action(action_name) # get in variables coerced_args = {} for key, value in action_args.items(): in_arg = action.argument(key) if not in_arg: arguments_str = ",".join([a.name for a in action.in_arguments()]) print(f"Unknown argument: {key}") print(f"Available arguments: {arguments_str}") sys.exit(1) coerced_args[key] = in_arg.coerce_python(value) # ensure all in variables given for in_arg in action.in_arguments(): if in_arg.name not in action_args: in_args = "\n".join( [ f" {in_arg.name}" for in_arg in sorted( action.in_arguments(), key=operator.attrgetter("name") ) ] ) print("Missing in-arguments") print(f"Known in-arguments:\n{in_args}") sys.exit(1) _LOGGER.debug( "Calling %s.%s, parameters:\n%s", service.service_id, action.name, "\n".join([f"{key}:{value}" for key, value in coerced_args.items()]), ) result = await action.async_call(**coerced_args) _LOGGER.debug( "Results:\n%s", "\n".join([f"{key}:{value}" for key, value in coerced_args.items()]), ) obj = { "timestamp": get_timestamp(), "service_id": service.service_id, "service_type": service.service_type, "action": action.name, "in_parameters": coerced_args, "out_parameters": result, } print(json.dumps(obj, indent=pprint_indent)) async def subscribe(description_url: str, service_names: Any) -> None: """Subscribe to service(s) and output updates.""" global event_handler # pylint: disable=global-statement device = await create_device(description_url) # start notify server/event handler source = (get_local_ip(device.device_url), 0) server = AiohttpNotifyServer(device.requester, source=source) await server.async_start_server() _LOGGER.debug("Listening on: %s", server.callback_url) # gather all wanted services if "*" in service_names: service_names = [service.service_type for service in device.all_services] services = [] for service_name in service_names: service = service_from_device(device, service_name) if not service: print(f"Unknown service: {service_name}") sys.exit(1) service.on_event = on_event services.append(service) # subscribe to services event_handler = server.event_handler for service in services: try: await event_handler.async_subscribe(service) except UpnpResponseError as ex: _LOGGER.error("Unable to subscribe to %s: %s", service, ex) # keep the webservice running while True: await asyncio.sleep(120) await event_handler.async_resubscribe_all() def source_target( source: Optional[str], target: Optional[str], target_port: int, ) -> Tuple[AddressTupleVXType, AddressTupleVXType]: """Determine source/target.""" # pylint: disable=too-many-branches, too-many-return-statements if source is None and target is None: return ( "0.0.0.0", 0, ), (SSDP_IP_V4, SSDP_PORT) if source is not None and target is None: if ":" not in source: # IPv4 return (source, 0), (SSDP_IP_V4, SSDP_PORT) # IPv6 if "%" in source: idx = source.index("%") source_ip, scope_id = source[:idx], int(source[idx + 1 :]) else: source_ip, scope_id = source, 0 return (source_ip, 0, 0, scope_id), (SSDP_IP_V6, SSDP_PORT, 0, scope_id) if source is None and target is not None: if ":" not in target: # IPv4 return ( "0.0.0.0", 0, ), (target, target_port or SSDP_PORT) # IPv6 if "%" in target: idx = target.index("%") target_ip, scope_id = target[:idx], int(target[idx + 1 :]) else: target_ip, scope_id = target, 0 return ("::", 0, 0, scope_id), ( target_ip, target_port or SSDP_PORT, 0, scope_id, ) source_version = 6 if ":" in (source or "") else 4 target_version = 6 if ":" in (target or "") else 4 if source is not None and target is not None and source_version != target_version: print("Error: Source and target do not match protocol") sys.exit(1) if source is not None and target is not None and ":" in target: if "%" in target: idx = target.index("%") target_ip, scope_id = target[:idx], int(target[idx + 1 :]) else: target_ip, scope_id = target, 0 return (source, 0, 0, scope_id), (target_ip, target_port, 0, scope_id) return (cast(str, source), 0), (cast(str, target), target_port) async def search(search_args: Any) -> None: """Discover devices.""" timeout = args.timeout search_target = search_args.search_target source, target = source_target( search_args.bind, search_args.target, search_args.target_port ) async def on_response(headers: CaseInsensitiveDict) -> None: print( json.dumps( {key: str(value) for key, value in headers.items()}, indent=pprint_indent, ) ) await async_ssdp_search( search_target=search_target, source=source, target=target, timeout=timeout, async_callback=on_response, ) async def advertisements(advertisement_args: Any) -> None: """Listen for advertisements.""" source, target = source_target( advertisement_args.bind, advertisement_args.target, advertisement_args.target_port, ) async def on_notify(headers: CaseInsensitiveDict) -> None: print( json.dumps( {key: str(value) for key, value in headers.items()}, indent=pprint_indent, ) ) listener = SsdpAdvertisementListener( async_on_alive=on_notify, async_on_byebye=on_notify, async_on_update=on_notify, source=source, target=target, ) await listener.async_start() try: while True: await asyncio.sleep(60) except KeyboardInterrupt: _LOGGER.debug("KeyboardInterrupt") await listener.async_stop() raise async def async_main() -> None: """Async main.""" if args.debug: _LOGGER.setLevel(logging.DEBUG) _LOGGER_LIB.setLevel(logging.DEBUG) _LOGGER_TRAFFIC.setLevel(logging.INFO) if args.debug_traffic: _LOGGER_TRAFFIC.setLevel(logging.DEBUG) if args.command == "call-action": await call_action(args.device, getattr(args, "call-action")) elif args.command == "subscribe": await subscribe(args.device, args.service) elif args.command == "search": await search(args) elif args.command == "advertisements": await advertisements(args) def main() -> None: """Set up async loop and run the main program.""" loop = asyncio.get_event_loop() try: loop.run_until_complete(async_main()) except KeyboardInterrupt: if event_handler: loop.run_until_complete(event_handler.async_unsubscribe_all()) finally: loop.close() if __name__ == "__main__": main() async_upnp_client-0.44.0/async_upnp_client/client.py000066400000000000000000001122061477256211100226710ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.client module.""" # pylint: disable=too-many-lines import io import logging import urllib.parse from abc import ABC from datetime import datetime, timezone from types import TracebackType from typing import ( Any, Callable, Dict, Generic, List, Mapping, Optional, Sequence, Set, Type, TypeVar, Union, ) from xml.etree import ElementTree as ET from xml.parsers import expat from xml.sax.saxutils import escape import defusedxml.ElementTree as DET import voluptuous as vol from async_upnp_client.const import ( NS, ActionArgumentInfo, ActionInfo, DeviceIcon, DeviceInfo, HttpRequest, HttpResponse, ServiceInfo, StateVariableInfo, ) from async_upnp_client.exceptions import ( UpnpActionError, UpnpActionResponseError, UpnpError, UpnpResponseError, UpnpValueError, UpnpXmlParseError, ) from async_upnp_client.utils import CaseInsensitiveDict _LOGGER = logging.getLogger(__name__) EventCallbackType = Callable[["UpnpService", Sequence["UpnpStateVariable"]], None] def default_on_pre_receive_device_spec(request: HttpRequest) -> HttpRequest: """Pre-receive device specification hook.""" # pylint: disable=unused-argument return request def default_on_post_receive_device_spec(response: HttpResponse) -> HttpResponse: """Post-receive device specification hook.""" # pylint: disable=unused-argument fixed_body = (response.body or "").rstrip(" \t\r\n\0") return HttpResponse(response.status_code, response.headers, fixed_body) def default_on_pre_receive_service_spec(request: HttpRequest) -> HttpRequest: """Pre-receive service specification hook.""" # pylint: disable=unused-argument return request def default_on_post_receive_service_spec(response: HttpResponse) -> HttpResponse: """Post-receive service specification hook.""" # pylint: disable=unused-argument fixed_body = (response.body or "").rstrip(" \t\r\n\0") return HttpResponse(response.status_code, response.headers, fixed_body) def default_on_pre_call_action( action: "UpnpAction", args: Mapping[str, Any], request: HttpRequest ) -> HttpRequest: """Pre-action call hook.""" # pylint: disable=unused-argument return request def default_on_post_call_action( action: "UpnpAction", response: HttpResponse ) -> HttpResponse: """Post-action call hook.""" # pylint: disable=unused-argument fixed_body = (response.body or "").rstrip(" \t\r\n\0") return HttpResponse(response.status_code, response.headers, fixed_body) class DisableXmlNamespaces: """Context manager to disable XML namespace handling.""" def __enter__(self) -> None: """Enter context manager.""" # pylint: disable=attribute-defined-outside-init self._old_parser_create = expat.ParserCreate def expat_parser_create( encoding: Optional[str] = None, namespace_separator: Optional[str] = None, intern: Optional[Dict[str, Any]] = None, ) -> expat.XMLParserType: # pylint: disable=unused-argument return self._old_parser_create(encoding, None, intern) expat.ParserCreate = expat_parser_create def __exit__( self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: """Exit context manager.""" expat.ParserCreate = self._old_parser_create class UpnpRequester(ABC): """ Abstract base class used for performing async HTTP requests. Implement method async_do_http_request() in your concrete class. """ # pylint: disable=too-few-public-methods async def async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request.""" raise NotImplementedError() class UpnpDevice: """UPnP Device representation.""" # pylint: disable=too-many-public-methods,too-many-instance-attributes def __init__( self, requester: UpnpRequester, device_info: DeviceInfo, services: Sequence["UpnpService"], embedded_devices: Sequence["UpnpDevice"], on_pre_receive_device_spec: Callable[ [HttpRequest], HttpRequest ] = default_on_pre_receive_device_spec, on_post_receive_device_spec: Callable[ [HttpResponse], HttpResponse ] = default_on_post_receive_device_spec, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments,too-many-positional-arguments self.requester = requester self.device_info = device_info self.services = {service.service_type: service for service in services} self.embedded_devices = { embedded_device.device_type: embedded_device for embedded_device in embedded_devices } self.on_pre_receive_device_spec = on_pre_receive_device_spec self.on_post_receive_device_spec = on_post_receive_device_spec self._parent_device: Optional["UpnpDevice"] = None # bind services to ourselves for service in services: service.device = self # bind devices to ourselves for embedded_device in embedded_devices: embedded_device.parent_device = self # SSDP headers. self.ssdp_headers: CaseInsensitiveDict = CaseInsensitiveDict() # Just initialized, mark available. self.available = True @property def parent_device(self) -> Optional["UpnpDevice"]: """Get parent UpnpDevice, if any.""" return self._parent_device @parent_device.setter def parent_device(self, parent_device: "UpnpDevice") -> None: """Set parent UpnpDevice.""" if self._parent_device is not None: raise UpnpError("UpnpDevice already bound to UpnpDevice") self._parent_device = parent_device @property def root_device(self) -> "UpnpDevice": """Get the root device, or self if self is the root device.""" if self._parent_device is None: return self return self._parent_device.root_device def find_device(self, device_type: str) -> Optional["UpnpDevice"]: """Find a (embedded) device with the given device_type.""" if self.device_type == device_type: return self for embedded_device in self.embedded_devices.values(): device = embedded_device.find_device(device_type) if device: return device return None def find_service(self, service_type: str) -> Optional["UpnpService"]: """Find a service with the give service_type.""" if service_type in self.services: return self.services[service_type] for embedded_device in self.embedded_devices.values(): service = embedded_device.find_service(service_type) if service: return service return None @property def all_devices(self) -> List["UpnpDevice"]: """Get all devices, self and embedded.""" devices = [self] for embedded_device in self.embedded_devices.values(): devices += embedded_device.all_devices return devices def get_devices_matching_udn(self, udn: str) -> List["UpnpDevice"]: """Get all devices matching udn.""" devices: List["UpnpDevice"] = [] if self.udn.lower() == udn: devices.append(self) for embedded_device in self.embedded_devices.values(): devices += embedded_device.get_devices_matching_udn(udn) return devices @property def all_services(self) -> List["UpnpService"]: """Get all services, from self and embedded devices.""" services: List["UpnpService"] = [] for device in self.all_devices: services += device.services.values() return services def reinit(self, new_device: "UpnpDevice") -> None: """Reinitialize self from another device.""" if self.device_type != new_device.device_type: raise UpnpError( f"Mismatch in device_type: {self.device_type} vs {new_device.device_type}" ) self.device_info = new_device.device_info # reinit embedded devices for device_type, embedded_device in self.embedded_devices.items(): new_embedded_device = new_device.embedded_devices[device_type] embedded_device.reinit(new_embedded_device) @property def name(self) -> str: """Get the name of this device.""" return self.device_info.friendly_name @property def friendly_name(self) -> str: """Get the friendly name of this device, alias for name.""" return self.device_info.friendly_name @property def manufacturer(self) -> str: """Get the manufacturer of this device.""" return self.device_info.manufacturer @property def manufacturer_url(self) -> Optional[str]: """Get the manufacturer URL of this device.""" return self.device_info.manufacturer_url @property def model_description(self) -> Optional[str]: """Get the model description of this device.""" return self.device_info.model_description @property def model_name(self) -> str: """Get the model name of this device.""" return self.device_info.model_name @property def model_number(self) -> Optional[str]: """Get the model number of this device.""" return self.device_info.model_number @property def model_url(self) -> Optional[str]: """Get the model URL of this device.""" return self.device_info.model_url @property def serial_number(self) -> Optional[str]: """Get the serial number of this device.""" return self.device_info.serial_number @property def udn(self) -> str: """Get UDN of this device.""" return self.device_info.udn @property def upc(self) -> Optional[str]: """Get UPC of this device.""" return self.device_info.upc @property def presentation_url(self) -> Optional[str]: """Get presentationURL of this device.""" return self.device_info.presentation_url @property def device_url(self) -> str: """Get the URL of this device.""" return self.device_info.url @property def device_type(self) -> str: """Get the device type of this device.""" return self.device_info.device_type @property def icons(self) -> Sequence[DeviceIcon]: """Get the icons for this device.""" return self.device_info.icons @property def xml(self) -> ET.Element: """Get the XML description for this device.""" return self.device_info.xml def has_service(self, service_type: str) -> bool: """Check if service by service_type is available.""" return service_type in self.services def service(self, service_type: str) -> "UpnpService": """Get service by service_type.""" return self.services[service_type] def service_id(self, service_id: str) -> Optional["UpnpService"]: """Get service by service_id.""" for service in self.services.values(): if service.service_id == service_id: return service return None async def async_ping(self) -> None: """Ping the device.""" bare_request = HttpRequest("GET", self.device_url, {}, None) request = self.on_pre_receive_device_spec(bare_request) await self.requester.async_http_request(request) def __str__(self) -> str: """To string.""" return f"" class UpnpService: """UPnP Service representation.""" # pylint: disable=too-many-instance-attributes def __init__( self, requester: UpnpRequester, service_info: ServiceInfo, state_variables: Sequence["UpnpStateVariable"], actions: Sequence["UpnpAction"], on_pre_call_action: Callable[ ["UpnpAction", Mapping[str, Any], HttpRequest], HttpRequest ] = default_on_pre_call_action, on_post_call_action: Callable[ ["UpnpAction", HttpResponse], HttpResponse ] = default_on_post_call_action, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments,too-many-positional-arguments self.requester = requester self._service_info = service_info self.state_variables = {sv.name: sv for sv in state_variables} self.actions = {ac.name: ac for ac in actions} self.on_pre_call_action = on_pre_call_action self.on_post_call_action = on_post_call_action self.on_event: Optional[EventCallbackType] = None self._device: Optional[UpnpDevice] = None # bind state variables to ourselves for state_var in state_variables: state_var.service = self # bind actions to ourselves for action in actions: action.service = self @property def device(self) -> UpnpDevice: """Get parent UpnpDevice.""" if not self._device: raise UpnpError("UpnpService not bound to UpnpDevice") return self._device @device.setter def device(self, device: UpnpDevice) -> None: """Set parent UpnpDevice.""" self._device = device @property def service_type(self) -> str: """Get service type for this UpnpService.""" return self._service_info.service_type @property def service_id(self) -> str: """Get service ID for this UpnpService.""" return self._service_info.service_id @property def scpd_url(self) -> str: """Get full SCPD-url for this UpnpService.""" url: str = urllib.parse.urljoin( self.device.device_url, self._service_info.scpd_url ) return url @property def control_url(self) -> str: """Get full control-url for this UpnpService.""" url: str = urllib.parse.urljoin( self.device.device_url, self._service_info.control_url ) return url @property def event_sub_url(self) -> str: """Get full event sub-url for this UpnpService.""" url: str = urllib.parse.urljoin( self.device.device_url, self._service_info.event_sub_url ) return url @property def xml(self) -> ET.Element: """Get the XML description for this service.""" return self._service_info.xml def has_state_variable(self, name: str) -> bool: """Check if self has state variable called name.""" if name not in self.state_variables and "}" in name: # possibly messed up namespaces, try again without namespace name = name.split("}")[1] return name in self.state_variables def state_variable(self, name: str) -> "UpnpStateVariable": """Get UPnpStateVariable by name.""" state_var = self.state_variables.get(name, None) # possibly messed up namespaces, try again without namespace if not state_var and "}" in name: name = name.split("}")[1] state_var = self.state_variables.get(name, None) if state_var is None: raise KeyError(name) return state_var def has_action(self, name: str) -> bool: """Check if self has action called name.""" return name in self.actions def action(self, name: str) -> "UpnpAction": """Get UPnpAction by name.""" return self.actions[name] async def async_call_action( self, action: Union["UpnpAction", str], **kwargs: Any ) -> Mapping[str, Any]: """ Call a UpnpAction. Parameters are in Python-values and coerced automatically to UPnP values. """ if isinstance(action, str): action = self.actions[action] result = await action.async_call(**kwargs) return result def notify_changed_state_variables(self, changes: Mapping[str, str]) -> None: """Do callback on UpnpStateVariable.value changes.""" changed_state_variables = [] for name, value in changes.items(): if not self.has_state_variable(name): _LOGGER.debug("State variable %s does not exist, ignoring", name) continue state_var = self.state_variable(name) try: state_var.upnp_value = value changed_state_variables.append(state_var) except UpnpValueError: _LOGGER.error("Got invalid value for %s: %s", state_var, value) if self.on_event: # pylint: disable=not-callable self.on_event(self, changed_state_variables) def __str__(self) -> str: """To string.""" udn = "unbound" if self._device: udn = self._device.udn return f"" def __repr__(self) -> str: """To repr.""" udn = "unbound" if self._device: udn = self._device.udn return f"" class UpnpAction: """Representation of an Action.""" class Argument: """Representation of an Argument of an Action.""" def __init__( self, argument_info: ActionArgumentInfo, state_variable: "UpnpStateVariable" ) -> None: """Initialize.""" self._argument_info = argument_info self._related_state_variable = state_variable self._value = None self.raw_upnp_value: Optional[str] = None def validate_value(self, value: Any) -> None: """Validate value against related UpnpStateVariable.""" self.related_state_variable.validate_value(value) @property def name(self) -> str: """Get the name.""" return self._argument_info.name @property def direction(self) -> str: """Get the direction.""" return self._argument_info.direction @property def related_state_variable(self) -> "UpnpStateVariable": """Get the related state variable.""" return self._related_state_variable @property def xml(self) -> ET.Element: """Get the XML description for this device.""" return self._argument_info.xml @property def value(self) -> Any: """Get Python value for this argument.""" return self._value @value.setter def value(self, value: Any) -> None: """Set Python value for this argument.""" self.validate_value(value) self._value = value @property def upnp_value(self) -> str: """Get UPnP value for this argument.""" return self.coerce_upnp(self.value) @upnp_value.setter def upnp_value(self, upnp_value: str) -> None: """Set UPnP value for this argument.""" self._value = self.coerce_python(upnp_value) def coerce_python(self, upnp_value: str) -> Any: """Coerce UPnP value to Python.""" return self.related_state_variable.coerce_python(upnp_value) def coerce_upnp(self, value: Any) -> str: """Coerce Python value to UPnP value.""" return self.related_state_variable.coerce_upnp(value) def __repr__(self) -> str: """To repr.""" return f"" def __init__( self, action_info: ActionInfo, arguments: List["UpnpAction.Argument"], non_strict: bool = False, ) -> None: """Initialize.""" self._action_info = action_info self._arguments = arguments self._service: Optional[UpnpService] = None self._non_strict = non_strict @property def name(self) -> str: """Get the name.""" return self._action_info.name @property def arguments(self) -> List["UpnpAction.Argument"]: """Get the arguments.""" return self._arguments @property def xml(self) -> ET.Element: """Get the XML for this action.""" return self._action_info.xml @property def service(self) -> UpnpService: """Get parent UpnpService.""" if not self._service: raise UpnpError("UpnpAction not bound to UpnpService") return self._service @service.setter def service(self, service: UpnpService) -> None: """Set parent UpnpService.""" self._service = service def __str__(self) -> str: """To string.""" return f"" def __repr__(self) -> str: """To repr.""" return f" {self.out_arguments()}>" def validate_arguments(self, **kwargs: Any) -> None: """ Validate arguments against in-arguments of self. The python type is expected. """ for arg in self.in_arguments(): if arg.name not in kwargs: raise UpnpError(f"Missing argument: {arg.name}") value = kwargs[arg.name] arg.validate_value(value) def in_arguments(self) -> List["UpnpAction.Argument"]: """Get all in-arguments.""" return [arg for arg in self.arguments if arg.direction == "in"] def out_arguments(self) -> List["UpnpAction.Argument"]: """Get all out-arguments.""" return [arg for arg in self.arguments if arg.direction == "out"] def argument( self, name: str, direction: Optional[str] = None ) -> Optional["UpnpAction.Argument"]: """Get an UpnpAction.Argument by name (and possibliy direction).""" for arg in self.arguments: if arg.name != name: continue if direction is not None and arg.direction != direction: continue return arg return None async def async_call(self, **kwargs: Any) -> Mapping[str, Any]: """Call an action with arguments.""" # do request _LOGGER.debug("Calling action: %s, args: %s", self.name, kwargs) bare_request = self.create_request(**kwargs) request = self.service.on_pre_call_action(self, kwargs, bare_request) bare_response = await self.service.requester.async_http_request(request) response = self.service.on_post_call_action(self, bare_response) if not isinstance(response.body, str): raise UpnpError( f"Did not receive a body when calling action: {self.name}, args: {kwargs}" ) if response.status_code != 200: try: xml = DET.fromstring(response.body) except ET.ParseError: pass else: self._parse_fault(xml, response.status_code, response.headers) # Couldn't parse body for fault details, raise generic response error _LOGGER.debug( "Error calling action, no information, action: %s, args: %s", self.name, kwargs, ) raise UpnpResponseError( status=response.status_code, headers=response.headers, message=f"Error during async_call(), " f"action: {self.name}, " f"args: {kwargs}, " f"status: {response.status_code}, " f"body: {response.body}", ) # parse body response_args = self.parse_response(self.service.service_type, response) _LOGGER.debug( "Called action: %s, args: %s, response_args: %s", self.name, kwargs, response_args, ) return response_args def create_request(self, **kwargs: Any) -> HttpRequest: """Create HTTP request for this to-be-called UpnpAction.""" # build URL control_url = self.service.control_url # construct SOAP body service_type = self.service.service_type soap_args = self._format_request_args(**kwargs) body = ( f'' f'' f"" f'' f"{soap_args}" f"" f"" f"" ) # construct SOAP header soap_action = f"{service_type}#{self.name}" headers = { "SOAPAction": f'"{soap_action}"', "Host": urllib.parse.urlparse(control_url).netloc, "Content-Type": 'text/xml; charset="utf-8"', } return HttpRequest("POST", control_url, headers, body) def _format_request_args(self, **kwargs: Any) -> str: self.validate_arguments(**kwargs) arg_strs = [ f"<{arg.name}>{escape(arg.coerce_upnp(kwargs[arg.name]))}" for arg in self.in_arguments() ] return "\n".join(arg_strs) def parse_response( self, service_type: str, http_response: HttpResponse ) -> Mapping[str, Any]: """Parse response from called Action.""" # pylint: disable=unused-argument stripped_response_body = http_response.body try: xml = DET.fromstring(stripped_response_body) except ET.ParseError as err: if self._non_strict: # Try again ignoring namespaces. try: with DisableXmlNamespaces(): parser = DET.XMLParser() source = io.StringIO(stripped_response_body) it = DET.iterparse(source, parser=parser) for _, el in it: _, _, el.tag = el.tag.rpartition(":") # Strip namespace. it_root = it.root # type: ET.Element xml = it_root except ET.ParseError as err2: _LOGGER.debug( "Unable to parse XML: %s\nXML:\n%s", err2, http_response.body ) raise UpnpXmlParseError(err2) from err2 else: _LOGGER.debug( "Unable to parse XML: %s\nXML:\n%s", err, http_response.body ) raise UpnpXmlParseError(err) from err # Check if a SOAP fault occurred. It should have been caught earlier, by # the device sending an HTTP 500 status, but not all devices do. self._parse_fault(xml) try: return self._parse_response_args(service_type, xml) except AttributeError: _LOGGER.debug("Could not parse response: %s", http_response.body) raise def _parse_response_args( self, service_type: str, xml: ET.Element ) -> Mapping[str, Any]: """Parse response arguments.""" args = {} query = f".//{{{service_type}}}{self.name}Response" response = xml.find(query, NS) # If no response was found, do a search ignoring namespaces when in non-strict mode. if self._non_strict: if response is None: query = f".//{{*}}{self.name}Response" response = xml.find(query, NS) # Perhaps namespaces were removed/ignored, try searching again. if response is None: query = ".//*Response" response = xml.find(query) if response is None: xml_str = ET.tostring(xml, encoding="unicode") raise UpnpError(f"Invalid response: {xml_str}") for arg_xml in response.findall("./"): name = arg_xml.tag arg = self.argument(name, "out") if not arg: if self._non_strict: continue xml_str = ET.tostring(xml, encoding="unicode") raise UpnpError( f"Invalid response, unknown argument: {name}, {xml_str}" ) arg.raw_upnp_value = arg_xml.text arg.upnp_value = arg_xml.text or "" args[name] = arg.value return args def _parse_fault( self, xml: ET.Element, status_code: Optional[int] = None, response_headers: Optional[Mapping] = None, ) -> None: """Parse SOAP fault and raise appropriate exception.""" # pylint: disable=too-many-branches fault = xml.find(".//soap_envelope:Body/soap_envelope:Fault", NS) if self._non_strict: if fault is None: fault = xml.find(".//{{*}}Body/{{*}}Fault", NS) if fault is None: fault = xml.find(".//{{*}}Body/{{*}}Fault") if fault is None: return error_code_str = fault.findtext(".//control:errorCode", None, NS) if self._non_strict: if not error_code_str: error_code_str = fault.findtext(".//{{*}}:errorCode", None, NS) if not error_code_str: error_code_str = fault.findtext(".//errorCode") if error_code_str: error_code: Optional[int] = int(error_code_str) else: error_code = None error_desc = fault.findtext(".//control:errorDescription", None, NS) if self._non_strict: if not error_desc: error_desc = fault.findtext(".//{{*}}:errorDescription", None, NS) if not error_desc: error_desc = fault.findtext(".//errorDescription") _LOGGER.debug( "Error calling action: %s, error code: %s, error desc: %s", self.name, error_code, error_desc, ) if status_code is not None: raise UpnpActionResponseError( error_code=error_code, error_desc=error_desc, status=status_code, headers=response_headers, message=f"Error during async_call(), " f"action: {self.name}, " f"status: {status_code}, " f"upnp error: {error_code} ({error_desc})", ) raise UpnpActionError( error_code=error_code, error_desc=error_desc, message=f"Error during async_call(), " f"action: {self.name}, " f"upnp error: {error_code} ({error_desc})", ) T = TypeVar("T") # pylint: disable=invalid-name _UNDEFINED = object() class UpnpStateVariable(Generic[T]): """Representation of a State Variable.""" # pylint: disable=too-many-instance-attributes UPNP_VALUE_ERROR = object() def __init__( self, state_variable_info: StateVariableInfo, schema: vol.Schema ) -> None: """Initialize.""" self._state_variable_info = state_variable_info self._schema = schema self._service: Optional[UpnpService] = None self._value: Optional[Any] = None # None, T or UPNP_VALUE_ERROR self._updated_at: Optional[datetime] = None # When py3.12 is the minimum version, we can switch # these to be @cached_property self._min_value: Optional[T] = _UNDEFINED # type: ignore[assignment] self._max_value: Optional[T] = _UNDEFINED # type: ignore[assignment] self._allowed_values: Set[T] = _UNDEFINED # type: ignore[assignment] self._normalized_allowed_values: Set[str] = _UNDEFINED # type: ignore[assignment] @property def service(self) -> UpnpService: """Get parent UpnpService.""" if not self._service: raise UpnpError("UpnpStateVariable not bound to UpnpService") return self._service @service.setter def service(self, service: UpnpService) -> None: """Set parent UpnpService.""" self._service = service @property def xml(self) -> ET.Element: """Get the XML for this State Variable.""" return self._state_variable_info.xml @property def data_type_mapping(self) -> Mapping[str, Callable]: """Get the data type (coercer) for this State Variable.""" return self._state_variable_info.type_info.data_type_mapping @property def data_type_python(self) -> Callable[[str], Any]: """Get the Python data type for this State Variable.""" return self.data_type_mapping["type"] @property def min_value(self) -> Optional[T]: """Min value for this UpnpStateVariable, if defined.""" if self._min_value is _UNDEFINED: min_ = self._state_variable_info.type_info.allowed_value_range.get("min") if min_ is not None: self._min_value = self.coerce_python(min_) else: self._min_value = None return self._min_value @property def max_value(self) -> Optional[T]: """Max value for this UpnpStateVariable, if defined.""" if self._max_value is _UNDEFINED: max_ = self._state_variable_info.type_info.allowed_value_range.get("max") if max_ is not None: self._max_value = self.coerce_python(max_) else: self._max_value = None return self._max_value @property def allowed_values(self) -> Set[T]: """Set with allowed values for this UpnpStateVariable, if defined.""" if self._allowed_values is _UNDEFINED: allowed_values = self._state_variable_info.type_info.allowed_values or [] self._allowed_values = { self.coerce_python(allowed_value) for allowed_value in allowed_values } return self._allowed_values @property def normalized_allowed_values(self) -> Set[str]: """Set with normalized allowed values for this UpnpStateVariable, if defined.""" if self._normalized_allowed_values is _UNDEFINED: self._normalized_allowed_values = { str(allowed_value).lower().strip() for allowed_value in self.allowed_values } return self._normalized_allowed_values @property def send_events(self) -> bool: """Check if this UpnpStatevariable send events.""" return self._state_variable_info.send_events @property def name(self) -> str: """Name of the UpnpStatevariable.""" return self._state_variable_info.name @property def data_type(self) -> str: """UPNP data type of UpnpStateVariable.""" return self._state_variable_info.type_info.data_type @property def default_value(self) -> Optional[T]: """Get default value for UpnpStateVariable, if defined.""" type_info = self._state_variable_info.type_info default_value = type_info.default_value if default_value is not None: value: T = self.coerce_python(default_value) return value return None def validate_value(self, value: T) -> None: """Validate value.""" try: self._schema(value) except vol.error.MultipleInvalid as ex: raise UpnpValueError(self.name, value) from ex @property def value(self) -> Optional[T]: """ Get the value, python typed. Invalid values are returned as None. """ if self._value is UpnpStateVariable.UPNP_VALUE_ERROR: return None return self._value @value.setter def value(self, value: Any) -> None: """Set value, python typed.""" self.validate_value(value) self._value = value self._updated_at = datetime.now(timezone.utc) @property def value_unchecked(self) -> Optional[T]: """ Get the value, python typed. If an event was received with an invalid value for this StateVariable (e.g., 'abc' for a 'ui4' StateVariable), then this will return UpnpStateVariable.UPNP_VALUE_ERROR instead of None. """ return self._value @property def upnp_value(self) -> str: """Get the value, UPnP typed.""" return self.coerce_upnp(self.value) @upnp_value.setter def upnp_value(self, upnp_value: str) -> None: """Set the value, UPnP typed.""" try: self.value = self.coerce_python(upnp_value) except ValueError as err: _LOGGER.debug('Error setting upnp_value "%s", error: %s', upnp_value, err) self._value = UpnpStateVariable.UPNP_VALUE_ERROR def coerce_python(self, upnp_value: str) -> Any: """Coerce value from UPNP to python.""" coercer = self.data_type_mapping["in"] return coercer(upnp_value) def coerce_upnp(self, value: Any) -> str: """Coerce value from python to UPNP.""" coercer = self.data_type_mapping["out"] coerced_value: str = coercer(value) return coerced_value @property def updated_at(self) -> Optional[datetime]: """ Get timestamp at which this UpnpStateVariable was updated. Return time in UTC. """ return self._updated_at def __str__(self) -> str: """To string.""" return f"" def __repr__(self) -> str: """To repr.""" return f"" async_upnp_client-0.44.0/async_upnp_client/client_factory.py000066400000000000000000000435421477256211100244260ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.client_factory module.""" import logging import urllib.parse from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence from xml.etree import ElementTree as ET import defusedxml.ElementTree as DET import voluptuous as vol from async_upnp_client.client import ( UpnpAction, UpnpDevice, UpnpError, UpnpRequester, UpnpService, UpnpStateVariable, default_on_post_call_action, default_on_post_receive_device_spec, default_on_post_receive_service_spec, default_on_pre_call_action, default_on_pre_receive_device_spec, default_on_pre_receive_service_spec, ) from async_upnp_client.const import ( NS, STATE_VARIABLE_TYPE_MAPPING, ActionArgumentInfo, ActionInfo, DeviceIcon, DeviceInfo, HttpRequest, HttpResponse, ServiceInfo, StateVariableInfo, StateVariableTypeInfo, ) from async_upnp_client.exceptions import ( UpnpResponseError, UpnpXmlContentError, UpnpXmlParseError, ) from async_upnp_client.utils import absolute_url _LOGGER = logging.getLogger(__name__) class UpnpFactory: """ Factory for UpnpService and friends. Use UpnpFactory.async_create_device() to instantiate a UpnpDevice from a description URL. The description URL can be retrieved by searching for the UPnP device on the network, or by listening for advertisements. """ # pylint: disable=too-few-public-methods,too-many-instance-attributes def __init__( self, requester: UpnpRequester, non_strict: bool = False, on_pre_receive_device_spec: Callable[ [HttpRequest], HttpRequest ] = default_on_pre_receive_device_spec, on_post_receive_device_spec: Callable[ [HttpResponse], HttpResponse ] = default_on_post_receive_device_spec, on_pre_receive_service_spec: Callable[ [HttpRequest], HttpRequest ] = default_on_pre_receive_service_spec, on_post_receive_service_spec: Callable[ [HttpResponse], HttpResponse ] = default_on_post_receive_service_spec, on_pre_call_action: Callable[ [UpnpAction, Mapping[str, Any], HttpRequest], HttpRequest ] = default_on_pre_call_action, on_post_call_action: Callable[ [UpnpAction, HttpResponse], HttpResponse ] = default_on_post_call_action, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments,too-many-positional-arguments self.requester = requester self._non_strict = non_strict self._on_pre_receive_device_spec = on_pre_receive_device_spec self._on_post_receive_device_spec = on_post_receive_device_spec self._on_pre_receive_service_spec = on_pre_receive_service_spec self._on_post_receive_service_spec = on_post_receive_service_spec self._on_pre_call_action = on_pre_call_action self._on_post_call_action = on_post_call_action async def async_create_device( self, description_url: str, ) -> UpnpDevice: """Create a UpnpDevice, with all of it UpnpServices.""" _LOGGER.debug("Creating device, description_url: %s", description_url) root_el = await self._async_get_device_spec(description_url) # get root device device_el = root_el.find("./device:device", NS) if device_el is None: raise UpnpXmlContentError("Could not find device element") return await self._async_create_device(device_el, description_url) async def _async_create_device( self, device_el: ET.Element, description_url: str ) -> UpnpDevice: """Create a device.""" device_info = self._parse_device_el(device_el, description_url) # get services services = [] for service_desc_el in device_el.findall( "./device:serviceList/device:service", NS ): service = await self._async_create_service(service_desc_el, description_url) services.append(service) embedded_devices = [] for embedded_device_el in device_el.findall( "./device:deviceList/device:device", NS ): embedded_device = await self._async_create_device( embedded_device_el, description_url ) embedded_devices.append(embedded_device) return UpnpDevice( self.requester, device_info, services, embedded_devices, self._on_pre_receive_device_spec, self._on_post_receive_device_spec, ) def _parse_device_el( self, device_desc_el: ET.Element, description_url: str ) -> DeviceInfo: """Parse device description XML.""" icons = [] for icon_el in device_desc_el.iterfind("./device:iconList/device:icon", NS): icon_url = icon_el.findtext("./device:url", "", NS) icon_url = absolute_url(description_url, icon_url) icon = DeviceIcon( mimetype=icon_el.findtext("./device:mimetype", "", NS), width=int(icon_el.findtext("./device:width", 0, NS)), height=int(icon_el.findtext("./device:height", 0, NS)), depth=int(icon_el.findtext("./device:depth", 0, NS)), url=icon_url, ) icons.append(icon) return DeviceInfo( device_type=device_desc_el.findtext("./device:deviceType", "", NS), friendly_name=device_desc_el.findtext("./device:friendlyName", "", NS), manufacturer=device_desc_el.findtext("./device:manufacturer", "", NS), manufacturer_url=device_desc_el.findtext( "./device:manufacturerURL", "", NS ), model_description=device_desc_el.findtext( "./device:modelDescription", None, NS ), model_name=device_desc_el.findtext("./device:modelName", "", NS), model_number=device_desc_el.findtext("./device:modelNumber", None, NS), model_url=device_desc_el.findtext("./device:modelURL", None, NS), serial_number=device_desc_el.findtext("./device:serialNumber", None, NS), udn=device_desc_el.findtext("./device:UDN", "", NS), upc=device_desc_el.findtext("./device:UPC", "", NS), presentation_url=device_desc_el.findtext( "./device:presentationURL", "", NS ), url=description_url, icons=icons, xml=device_desc_el, ) async def _async_create_service( self, service_description_el: ET.Element, base_url: str ) -> UpnpService: """Retrieve the SCPD for a service and create a UpnpService from it.""" scpd_url = service_description_el.findtext("device:SCPDURL", None, NS) scpd_url = urllib.parse.urljoin(base_url, scpd_url) try: scpd_el = await self._async_get_service_spec(scpd_url) except UpnpXmlParseError as err: if not self._non_strict: raise _LOGGER.debug("Ignoring bad XML document from URL %s: %s", scpd_url, err) scpd_el = ET.Element(f"{{{NS['service']}}}scpd") if not self._non_strict and scpd_el.tag != f"{{{NS['service']}}}scpd": raise UpnpXmlContentError(f"Invalid document root: {scpd_el.tag}") service_info = self._parse_service_el(service_description_el) state_vars = self._create_state_variables(scpd_el) actions = self._create_actions(scpd_el, state_vars) return UpnpService( self.requester, service_info, state_vars, actions, self._on_pre_call_action, self._on_post_call_action, ) def _parse_service_el(self, service_description_el: ET.Element) -> ServiceInfo: """Parse service description XML.""" return ServiceInfo( service_id=service_description_el.findtext("device:serviceId", "", NS), service_type=service_description_el.findtext("device:serviceType", "", NS), control_url=service_description_el.findtext("device:controlURL", "", NS), event_sub_url=service_description_el.findtext("device:eventSubURL", "", NS), scpd_url=service_description_el.findtext("device:SCPDURL", "", NS), xml=service_description_el, ) def _create_state_variables(self, scpd_el: ET.Element) -> List[UpnpStateVariable]: """Create UpnpStateVariables from scpd_el.""" service_state_table_el = scpd_el.find("./service:serviceStateTable", NS) if service_state_table_el is None: if self._non_strict: _LOGGER.debug("Could not find service state table element") return [] raise UpnpXmlContentError("Could not find service state table element") state_vars = [] for state_var_el in service_state_table_el.findall( "./service:stateVariable", NS ): state_var = self._create_state_variable(state_var_el) state_vars.append(state_var) return state_vars def _create_state_variable( self, state_variable_el: ET.Element ) -> UpnpStateVariable: """Create UpnpStateVariable from state_variable_el.""" state_variable_info = self._parse_state_variable_el(state_variable_el) type_info = state_variable_info.type_info schema = self._state_variable_create_schema(type_info) return UpnpStateVariable(state_variable_info, schema) def _parse_state_variable_el( self, state_variable_el: ET.Element ) -> StateVariableInfo: """Parse XML for state variable.""" # send events send_events = False if "sendEvents" in state_variable_el.attrib: send_events = state_variable_el.attrib["sendEvents"] == "yes" elif state_variable_el.find("service:sendEventsAttribute", NS) is not None: send_events = ( state_variable_el.findtext("service:sendEventsAttribute", None, NS) == "yes" ) else: _LOGGER.debug( "Invalid XML for state variable/send events: %s", ET.tostring(state_variable_el, encoding="unicode"), ) # data type data_type = state_variable_el.findtext("service:dataType", None, NS) if data_type is None or data_type not in STATE_VARIABLE_TYPE_MAPPING: raise UpnpError(f"Unsupported data type: {data_type}") data_type_mapping = STATE_VARIABLE_TYPE_MAPPING[data_type] # default value default_value = state_variable_el.findtext("service:defaultValue", None, NS) # allowed value ranges allowed_value_range: Dict[str, Optional[str]] = {} allowed_value_range_el = state_variable_el.find("service:allowedValueRange", NS) if allowed_value_range_el is not None: allowed_value_range = { "min": allowed_value_range_el.findtext("service:minimum", None, NS), "max": allowed_value_range_el.findtext("service:maximum", None, NS), "step": allowed_value_range_el.findtext("service:step", None, NS), } # allowed value list allowed_values: Optional[List[str]] = None allowed_value_list_el = state_variable_el.find("service:allowedValueList", NS) if allowed_value_list_el is not None: allowed_values = [ v.text for v in allowed_value_list_el.findall("service:allowedValue", NS) if v.text is not None ] type_info = StateVariableTypeInfo( data_type=data_type, data_type_mapping=data_type_mapping, default_value=default_value, allowed_value_range=allowed_value_range, allowed_values=allowed_values, xml=state_variable_el, ) name = state_variable_el.findtext("service:name", "", NS).strip() return StateVariableInfo( name=name, send_events=send_events, type_info=type_info, xml=state_variable_el, ) def _state_variable_create_schema( self, type_info: StateVariableTypeInfo ) -> vol.Schema: """Create schema.""" # construct validators validators = [] data_type_upnp = type_info.data_type data_type_mapping = STATE_VARIABLE_TYPE_MAPPING[data_type_upnp] data_type = data_type_mapping["type"] validators.append(data_type) data_type_validator = data_type_mapping.get("validator") if data_type_validator: validators.append(data_type_validator) if not self._non_strict: in_coercer = data_type_mapping["in"] if type_info.allowed_values: allowed_values = [ in_coercer(allowed_value) for allowed_value in type_info.allowed_values ] in_ = vol.In(allowed_values) validators.append(in_) if type_info.allowed_value_range: min_ = type_info.allowed_value_range.get("min", None) max_ = type_info.allowed_value_range.get("max", None) min_ = in_coercer(min_) if min_ else None max_ = in_coercer(max_) if max_ else None if min_ is not None or max_ is not None: range_ = vol.Range(min=min_, max=max_) validators.append(range_) # construct key key = vol.Required("value") if type_info.default_value is not None and type_info.default_value != "": default_value: Any = type_info.default_value if data_type == bool: default_value = default_value == "1" else: default_value = data_type(default_value) key.default = default_value return vol.Schema(vol.All(*validators)) def _create_actions( self, scpd_el: ET.Element, state_variables: Sequence[UpnpStateVariable] ) -> List[UpnpAction]: """Create UpnpActions from scpd_el.""" action_list_el = scpd_el.find("./service:actionList", NS) if action_list_el is None: return [] actions = [] for action_el in action_list_el.findall("./service:action", NS): action = self._create_action(action_el, state_variables) actions.append(action) return actions def _create_action( self, action_el: ET.Element, state_variables: Sequence[UpnpStateVariable] ) -> UpnpAction: """Create a UpnpAction from action_el.""" action_info = self._parse_action_el(action_el) svs = {sv.name: sv for sv in state_variables} arguments = [ UpnpAction.Argument(arg_info, svs[arg_info.state_variable_name]) for arg_info in action_info.arguments ] return UpnpAction(action_info, arguments, non_strict=self._non_strict) def _parse_action_el(self, action_el: ET.Element) -> ActionInfo: """Parse XML for action.""" # build arguments args: List[ActionArgumentInfo] = [] for argument_el in action_el.findall( "./service:argumentList/service:argument", NS ): argument_name = argument_el.findtext("service:name", None, NS) if argument_name is None: _LOGGER.debug("Caught Action Argument without a name, ignoring") continue direction = argument_el.findtext("service:direction", None, NS) if direction is None: _LOGGER.debug("Caught Action Argument without a direction, ignoring") continue state_variable_name = argument_el.findtext( "service:relatedStateVariable", None, NS ) if state_variable_name is None: _LOGGER.debug( "Caught Action Argument without a State Variable name, ignoring" ) continue argument_info = ActionArgumentInfo( name=argument_name, direction=direction, state_variable_name=state_variable_name, xml=argument_el, ) args.append(argument_info) action_name = action_el.findtext("service:name", None, NS) if action_name is None: _LOGGER.debug('Caught Action without a name, using default "nameless"') action_name = "nameless" return ActionInfo(name=action_name, arguments=args, xml=action_el) async def _async_get_device_spec(self, url: str) -> ET.Element: """Get a url.""" bare_request = HttpRequest("GET", url, {}, None) request = self._on_pre_receive_device_spec(bare_request) bare_response = await self.requester.async_http_request(request) response = self._on_post_receive_device_spec(bare_response) return self._read_spec_from_reponse(response) async def _async_get_service_spec(self, url: str) -> ET.Element: """Get a url.""" bare_request = HttpRequest("GET", url, {}, None) request = self._on_pre_receive_service_spec(bare_request) bare_response = await self.requester.async_http_request(request) response = self._on_post_receive_service_spec(bare_response) return self._read_spec_from_reponse(response) def _read_spec_from_reponse(self, response: HttpResponse) -> ET.Element: """Read XML specification from response.""" if response.status_code != 200: raise UpnpResponseError( status=response.status_code, headers=response.headers ) description: str = response.body or "" try: element: ET.Element = DET.fromstring(description) return element except ET.ParseError as err: _LOGGER.debug("Unable to parse XML: %s\nXML:\n%s", err, description) raise UpnpXmlParseError(err) from err async_upnp_client-0.44.0/async_upnp_client/const.py000066400000000000000000000136361477256211100225500ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.const module.""" from dataclasses import dataclass from datetime import date, datetime, time from enum import Enum from ipaddress import IPv4Address, IPv6Address from typing import ( Any, Callable, List, Mapping, MutableMapping, NamedTuple, Optional, Tuple, Union, ) from xml.etree import ElementTree as ET from async_upnp_client.utils import parse_date_time, require_tzinfo IPvXAddress = Union[IPv4Address, IPv6Address] # pylint: disable=invalid-name AddressTupleV4Type = Tuple[str, int] AddressTupleV6Type = Tuple[str, int, int, int] AddressTupleVXType = Union[ # pylint: disable=invalid-name AddressTupleV4Type, AddressTupleV6Type ] NS = { "soap_envelope": "http://schemas.xmlsoap.org/soap/envelope/", "device": "urn:schemas-upnp-org:device-1-0", "service": "urn:schemas-upnp-org:service-1-0", "event": "urn:schemas-upnp-org:event-1-0", "control": "urn:schemas-upnp-org:control-1-0", } MIME_TO_UPNP_CLASS_MAPPING: Mapping[str, str] = { "audio": "object.item.audioItem", "video": "object.item.videoItem", "image": "object.item.imageItem", "application/dash+xml": "object.item.videoItem", "application/x-mpegurl": "object.item.videoItem", "application/vnd.apple.mpegurl": "object.item.videoItem", } STATE_VARIABLE_TYPE_MAPPING: Mapping[str, Mapping[str, Callable]] = { "ui1": {"type": int, "in": int, "out": str}, "ui2": {"type": int, "in": int, "out": str}, "ui4": {"type": int, "in": int, "out": str}, "ui8": {"type": int, "in": int, "out": str}, "i1": {"type": int, "in": int, "out": str}, "i2": {"type": int, "in": int, "out": str}, "i4": {"type": int, "in": int, "out": str}, "i8": {"type": int, "in": int, "out": str}, "int": {"type": int, "in": int, "out": str}, "r4": {"type": float, "in": float, "out": str}, "r8": {"type": float, "in": float, "out": str}, "number": {"type": float, "in": float, "out": str}, "fixed.14.4": {"type": float, "in": float, "out": str}, "float": {"type": float, "in": float, "out": str}, "char": {"type": str, "in": str, "out": str}, "string": {"type": str, "in": str, "out": str}, "boolean": { "type": bool, "in": lambda s: s.lower() in ["1", "true", "yes"], "out": lambda b: "1" if b else "0", }, "bin.base64": {"type": str, "in": str, "out": str}, "bin.hex": {"type": str, "in": str, "out": str}, "uri": {"type": str, "in": str, "out": str}, "uuid": {"type": str, "in": str, "out": str}, "date": {"type": date, "in": parse_date_time, "out": lambda d: d.isoformat()}, "dateTime": { "type": datetime, "in": parse_date_time, "out": lambda dt: dt.isoformat("T", "seconds"), }, "dateTime.tz": { "type": datetime, "validator": require_tzinfo, "in": parse_date_time, "out": lambda dt: dt.isoformat("T", "seconds"), }, "time": { "type": time, "in": parse_date_time, "out": lambda t: t.isoformat("seconds"), }, "time.tz": { "type": time, "validator": require_tzinfo, "in": parse_date_time, "out": lambda t: t.isoformat("T", "seconds"), }, } class DeviceIcon(NamedTuple): """Device icon.""" mimetype: str width: int height: int depth: int url: str class DeviceInfo(NamedTuple): """Device info.""" device_type: str friendly_name: str manufacturer: str manufacturer_url: Optional[str] model_description: Optional[str] model_name: str model_number: Optional[str] model_url: Optional[str] serial_number: Optional[str] udn: str upc: Optional[str] presentation_url: Optional[str] url: str icons: List[DeviceIcon] xml: ET.Element class ServiceInfo(NamedTuple): """Service info.""" service_id: str service_type: str control_url: str event_sub_url: str scpd_url: str xml: ET.Element class ActionArgumentInfo(NamedTuple): """Action argument info.""" name: str direction: str state_variable_name: str xml: ET.Element class ActionInfo(NamedTuple): """Action info.""" name: str arguments: List[ActionArgumentInfo] xml: ET.Element @dataclass(frozen=True) class HttpRequest: """HTTP request.""" method: str url: str headers: Mapping[str, str] body: Optional[str] @dataclass(frozen=True) class HttpResponse: """HTTP response.""" status_code: int headers: Mapping[str, str] body: Optional[str] @dataclass(frozen=True) class StateVariableTypeInfo: """State variable type info.""" data_type: str data_type_mapping: Mapping[str, Callable] default_value: Optional[str] allowed_value_range: Mapping[str, Optional[str]] allowed_values: Optional[List[str]] xml: ET.Element @dataclass(frozen=True) class EventableStateVariableTypeInfo(StateVariableTypeInfo): """Eventable State variable type info.""" max_rate: Optional[float] @dataclass(frozen=True) class StateVariableInfo: """State variable info.""" name: str send_events: bool type_info: StateVariableTypeInfo xml: ET.Element # Headers SsdpHeaders = MutableMapping[str, Any] NotificationType = str # NT header UniqueServiceName = str # USN header SearchTarget = str # ST header UniqueDeviceName = str # UDN DeviceOrServiceType = str # Event handler ServiceId = str # SID class NotificationSubType(str, Enum): """NTS header.""" SSDP_ALIVE = "ssdp:alive" SSDP_BYEBYE = "ssdp:byebye" SSDP_UPDATE = "ssdp:update" class SsdpSource(str, Enum): """SSDP source.""" ADVERTISEMENT = "advertisement" SEARCH = "search" # More detailed SEARCH_ALIVE = "search_alive" SEARCH_CHANGED = "search_changed" # More detailed. ADVERTISEMENT_ALIVE = "advertisement_alive" ADVERTISEMENT_BYEBYE = "advertisement_byebye" ADVERTISEMENT_UPDATE = "advertisement_update" async_upnp_client-0.44.0/async_upnp_client/description_cache.py000066400000000000000000000102251477256211100250570ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.description_cache module.""" import asyncio import logging from typing import Any, Dict, Mapping, Optional, Tuple, Union, cast import aiohttp import defusedxml.ElementTree as DET from async_upnp_client.client import UpnpRequester from async_upnp_client.const import HttpRequest from async_upnp_client.exceptions import UpnpResponseError from async_upnp_client.utils import etree_to_dict _LOGGER = logging.getLogger(__name__) _UNDEF = object() DescriptionType = Optional[Mapping[str, Any]] def _description_xml_to_dict(description_xml: str) -> Optional[Mapping[str, str]]: """Convert description (XML) to dict.""" try: tree = DET.fromstring(description_xml) except DET.ParseError as err: _LOGGER.debug("Error parsing %s: %s", description_xml, err) return None root = etree_to_dict(tree).get("root") if root is None: return None return root.get("device") class DescriptionCache: """Cache for descriptions (xml).""" def __init__(self, requester: UpnpRequester): """Initialize.""" self._requester = requester self._cache_dict: Dict[str, Union[asyncio.Event, DescriptionType]] = {} async def async_get_description_xml(self, location: str) -> Optional[str]: """Get a description as XML, either from cache or download it.""" try: return await self._async_fetch_description(location) except Exception: # pylint: disable=broad-except # If it fails, cache the failure so we do not keep trying over and over _LOGGER.exception("Failed to fetch description from: %s", location) return None def peek_description_dict( self, location: Optional[str] ) -> Tuple[bool, DescriptionType]: """Peek a description as dict, only try the cache.""" if location is None: return True, None description = self._cache_dict.get(location, _UNDEF) if description is _UNDEF: return False, None if isinstance(description, asyncio.Event): return False, None return True, cast(DescriptionType, description) async def async_get_description_dict( self, location: Optional[str] ) -> DescriptionType: """Get a description as dict, either from cache or download it.""" if location is None: return None cache_dict_or_evt = self._cache_dict.get(location, _UNDEF) if isinstance(cache_dict_or_evt, asyncio.Event): await cache_dict_or_evt.wait() elif cache_dict_or_evt is _UNDEF: evt = self._cache_dict[location] = asyncio.Event() try: description_xml = await self.async_get_description_xml(location) except UpnpResponseError: self._cache_dict[location] = None else: if description_xml: self._cache_dict[location] = _description_xml_to_dict( description_xml ) else: self._cache_dict[location] = None evt.set() return cast(DescriptionType, self._cache_dict[location]) def uncache_description(self, location: str) -> None: """Uncache a description.""" if location in self._cache_dict: del self._cache_dict[location] async def _async_fetch_description(self, location: str) -> Optional[str]: """Download a description from location.""" try: for _ in range(2): request = HttpRequest("GET", location, {}, None) response = await self._requester.async_http_request(request) if response.status_code != 200: raise UpnpResponseError( status=response.status_code, headers=response.headers ) return response.body # Samsung Smart TV sometimes returns an empty document the # first time. Retry once. except (aiohttp.ClientError, asyncio.TimeoutError) as err: _LOGGER.debug("Error fetching %s: %s", location, err) return None async_upnp_client-0.44.0/async_upnp_client/device_updater.py000066400000000000000000000106131477256211100243750ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.device_updater module.""" import logging from typing import Optional from async_upnp_client.advertisement import SsdpAdvertisementListener from async_upnp_client.client import UpnpDevice from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import AddressTupleVXType from async_upnp_client.utils import CaseInsensitiveDict _LOGGER = logging.getLogger(__name__) class DeviceUpdater: """ Device updater. Listens for SSDP advertisements and updates device inline when needed. Inline meaning that it keeps the original UpnpDevice instance. So be sure to keep only references to the UpnpDevice, as a device might decide to remove a service after an update! """ def __init__( self, device: UpnpDevice, factory: UpnpFactory, source: Optional[AddressTupleVXType] = None, ) -> None: """Initialize.""" self._device = device self._factory = factory self._listener = SsdpAdvertisementListener( async_on_alive=self._async_on_alive, async_on_byebye=self._async_on_byebye, async_on_update=self._async_on_update, source=source, ) async def async_start(self) -> None: """Start listening for notifications.""" _LOGGER.debug("Start listening for notifications.") await self._listener.async_start() async def async_stop(self) -> None: """Stop listening for notifications.""" _LOGGER.debug("Stop listening for notifications.") await self._listener.async_stop() async def _async_on_alive(self, headers: CaseInsensitiveDict) -> None: """Handle on alive.""" # Ensure for root devices only. if headers.get("nt") != "upnp:rootdevice": return # Ensure for our device. if headers.get("_udn") != self._device.udn: return _LOGGER.debug("Handling alive: %s", headers) await self._async_handle_alive_update(headers) async def _async_on_byebye(self, headers: CaseInsensitiveDict) -> None: """Handle on byebye.""" _LOGGER.debug("Handling on_byebye: %s", headers) self._device.available = False async def _async_on_update(self, headers: CaseInsensitiveDict) -> None: """Handle on update.""" # Ensure for root devices only. if headers.get("nt") != "upnp:rootdevice": return # Ensure for our device. if headers.get("_udn") != self._device.udn: return _LOGGER.debug("Handling update: %s", headers) await self._async_handle_alive_update(headers) async def _async_handle_alive_update(self, headers: CaseInsensitiveDict) -> None: """Handle on_alive or on_update.""" do_reinit = False # Handle BOOTID.UPNP.ORG. boot_id = headers.get("BOOTID.UPNP.ORG") device_boot_id = self._device.ssdp_headers.get("BOOTID.UPNP.ORG") if boot_id and boot_id != device_boot_id: _LOGGER.debug("New boot_id: %s, old boot_id: %s", boot_id, device_boot_id) do_reinit = True # Handle CONFIGID.UPNP.ORG. config_id = headers.get("CONFIGID.UPNP.ORG") device_config_id = self._device.ssdp_headers.get("CONFIGID.UPNP.ORG") if config_id and config_id != device_config_id: _LOGGER.debug( "New config_id: %s, old config_id: %s", config_id, device_config_id, ) do_reinit = True # Handle LOCATION. location = headers.get("LOCATION") if location and self._device.device_url != location: _LOGGER.debug( "New location: %s, old location: %s", location, self._device.device_url ) do_reinit = True if location and do_reinit: await self._reinit_device(location, headers) # We heard from it, so mark it available. self._device.available = True async def _reinit_device( self, location: str, ssdp_headers: CaseInsensitiveDict ) -> None: """Reinitialize device.""" # pylint: disable=protected-access _LOGGER.debug("Reinitializing device, location: %s", location) new_device = await self._factory.async_create_device(location) self._device.reinit(new_device) self._device.ssdp_headers = ssdp_headers async_upnp_client-0.44.0/async_upnp_client/event_handler.py000066400000000000000000000404031477256211100242300ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.event_handler module.""" import asyncio import logging import weakref from abc import ABC from datetime import timedelta from http import HTTPStatus from ipaddress import ip_address from typing import Callable, Dict, Optional, Set, Tuple, Type, Union from urllib.parse import urlparse import defusedxml.ElementTree as DET from async_upnp_client.client import UpnpDevice, UpnpRequester, UpnpService from async_upnp_client.const import NS, HttpRequest, IPvXAddress, ServiceId from async_upnp_client.exceptions import ( UpnpConnectionError, UpnpError, UpnpResponseError, UpnpSIDError, ) from async_upnp_client.utils import get_local_ip _LOGGER = logging.getLogger(__name__) def default_on_pre_notify(request: HttpRequest) -> HttpRequest: """Pre-notify hook.""" # pylint: disable=unused-argument fixed_body = (request.body or "").rstrip(" \t\r\n\0") return HttpRequest(request.method, request.url, request.headers, fixed_body) class UpnpNotifyServer(ABC): """ Base Notify Server, which binds to a UpnpEventHandler. A single UpnpNotifyServer/UpnpEventHandler can be shared with multiple UpnpDevices. """ @property def callback_url(self) -> str: """Return callback URL on which we are callable.""" raise NotImplementedError() async def async_start_server(self) -> None: """Start the server.""" raise NotImplementedError() async def async_stop_server(self) -> None: """Stop the server.""" raise NotImplementedError() class UpnpEventHandler: """ Handles UPnP eventing. An incoming NOTIFY request should be pass to handle_notify(). subscribe/resubscribe/unsubscribe handle subscriptions. When using a reverse proxy in combination with a event handler, you should use the option to override the callback url. A single UpnpNotifyServer/UpnpEventHandler can be shared with multiple UpnpDevices. """ def __init__( self, notify_server: UpnpNotifyServer, requester: UpnpRequester, on_pre_notify: Callable[[HttpRequest], HttpRequest] = default_on_pre_notify, ) -> None: """ Initialize. notify_server is the notify server which is actually listening on a socket. """ self._notify_server = notify_server self._requester = requester self.on_pre_notify = on_pre_notify self._subscriptions: weakref.WeakValueDictionary[ServiceId, UpnpService] = ( weakref.WeakValueDictionary() ) self._backlog: Dict[ServiceId, HttpRequest] = {} @property def callback_url(self) -> str: """Return callback URL on which we are callable.""" return self._notify_server.callback_url def sid_for_service(self, service: UpnpService) -> Optional[ServiceId]: """Get the service connected to SID.""" for sid, subscribed_service in self._subscriptions.items(): if subscribed_service == service: return sid return None def service_for_sid(self, sid: ServiceId) -> Optional[UpnpService]: """Get a UpnpService for SID.""" return self._subscriptions.get(sid) def _sid_and_service( self, service_or_sid: Union[UpnpService, ServiceId] ) -> Tuple[ServiceId, UpnpService]: """ Resolve a SID or service to both SID and service. :raise KeyError: Cannot determine SID from UpnpService, or vice versa. """ sid: Optional[ServiceId] service: Optional[UpnpService] if isinstance(service_or_sid, UpnpService): service = service_or_sid sid = self.sid_for_service(service) if not sid: raise KeyError(f"Unknown UpnpService {service}") else: sid = service_or_sid service = self.service_for_sid(sid) if not service: raise KeyError(f"Unknown SID {sid}") return sid, service async def handle_notify(self, http_request: HttpRequest) -> HTTPStatus: """Handle a NOTIFY request.""" http_request = self.on_pre_notify(http_request) # ensure valid request if "NT" not in http_request.headers or "NTS" not in http_request.headers: return HTTPStatus.BAD_REQUEST if ( http_request.headers["NT"] != "upnp:event" or http_request.headers["NTS"] != "upnp:propchange" or "SID" not in http_request.headers ): return HTTPStatus.PRECONDITION_FAILED sid: ServiceId = http_request.headers["SID"] service = self.service_for_sid(sid) # SID not known yet? store it in the backlog # Some devices don't behave nicely and send events before the SUBSCRIBE call is done. if not service: _LOGGER.debug("Storing NOTIFY in backlog for SID: %s", sid) self._backlog[sid] = http_request return HTTPStatus.OK # decode event and send updates to service changes = {} el_root = DET.fromstring(http_request.body) for el_property in el_root.findall("./event:property", NS): for el_state_var in el_property: name = el_state_var.tag value = el_state_var.text or "" changes[name] = value # send changes to service service.notify_changed_state_variables(changes) return HTTPStatus.OK async def async_subscribe( self, service: UpnpService, timeout: timedelta = timedelta(seconds=1800), ) -> Tuple[ServiceId, timedelta]: """ Subscription to a UpnpService. Be sure to re-subscribe before the subscription timeout passes. :param service: UpnpService to subscribe to self :param timeout: Timeout of subscription :return: SID (subscription ID), renewal timeout (may be different to supplied timeout) :raise UpnpResponseError: Error in response to subscription request :raise UpnpSIDError: No SID received for subscription :raise UpnpConnectionError: Device might be offline. :raise UpnpCommunicationError (or subclass): Error while performing subscription request. """ _LOGGER.debug( "Subscribing to: %s, callback URL: %s", service, self.callback_url ) # do SUBSCRIBE request headers = { "NT": "upnp:event", "TIMEOUT": "Second-" + str(timeout.seconds), "HOST": urlparse(service.event_sub_url).netloc, "CALLBACK": f"<{self.callback_url}>", } backlog_request = HttpRequest("SUBSCRIBE", service.event_sub_url, headers, None) response = await self._requester.async_http_request(backlog_request) # check results if response.status_code != 200: _LOGGER.debug("Did not receive 200, but %s", response.status_code) raise UpnpResponseError( status=response.status_code, headers=response.headers ) if "sid" not in response.headers: _LOGGER.debug("No SID received, aborting subscribe") raise UpnpSIDError # Device can give a different TIMEOUT header than what we have provided. if ( "timeout" in response.headers and response.headers["timeout"] != "Second-infinite" and "Second-" in response.headers["timeout"] ): response_timeout = response.headers["timeout"] timeout_seconds = int(response_timeout[7:]) # len("Second-") == 7 timeout = timedelta(seconds=timeout_seconds) sid: ServiceId = response.headers["sid"] self._subscriptions[sid] = service _LOGGER.debug( "Subscribed, service: %s, SID: %s, timeout: %s", service, sid, timeout ) # replay any backlog we have for this service if sid in self._backlog: _LOGGER.debug("Re-playing backlogged NOTIFY for SID: %s", sid) backlog_request = self._backlog[sid] await self.handle_notify(backlog_request) del self._backlog[sid] return sid, timeout async def _async_do_resubscribe( self, service: UpnpService, sid: ServiceId, timeout: timedelta = timedelta(seconds=1800), ) -> Tuple[ServiceId, timedelta]: """Perform only a resubscribe, caller can retry subscribe if this fails.""" # do SUBSCRIBE request headers = { "HOST": urlparse(service.event_sub_url).netloc, "SID": sid, "TIMEOUT": "Second-" + str(timeout.total_seconds()), } request = HttpRequest("SUBSCRIBE", service.event_sub_url, headers, None) response = await self._requester.async_http_request(request) # check results if response.status_code != 200: _LOGGER.debug("Did not receive 200, but %s", response.status_code) raise UpnpResponseError( status=response.status_code, headers=response.headers ) # Devices should return the SID when re-subscribe, # but in case it doesn't, use the new SID. if "sid" in response.headers and response.headers["sid"]: new_sid: ServiceId = response.headers["sid"] if new_sid != sid: del self._subscriptions[sid] sid = new_sid # Device can give a different TIMEOUT header than what we have provided. if ( "timeout" in response.headers and response.headers["timeout"] != "Second-infinite" and "Second-" in response.headers["timeout"] ): response_timeout = response.headers["timeout"] timeout_seconds = int(response_timeout[7:]) # len("Second-") == 7 timeout = timedelta(seconds=timeout_seconds) self._subscriptions[sid] = service _LOGGER.debug( "Resubscribed, service: %s, SID: %s, timeout: %s", service, sid, timeout ) return sid, timeout async def async_resubscribe( self, service_or_sid: Union[UpnpService, ServiceId], timeout: timedelta = timedelta(seconds=1800), ) -> Tuple[ServiceId, timedelta]: """ Renew subscription to a UpnpService. :param service_or_sid: UpnpService or existing SID to resubscribe :param timeout: Timeout of subscription :return: SID (subscription ID), renewal timeout (may be different to supplied timeout) :raise KeyError: Supplied service_or_sid is not known. :raise UpnpResponseError: Error in response to subscription request :raise UpnpSIDError: No SID received for subscription :raise UpnpConnectionError: Device might be offline. :raise UpnpCommunicationError (or subclass): Error while performing subscription request. """ _LOGGER.debug("Resubscribing to: %s", service_or_sid) # Try a regular resubscribe. If that fails, delete old subscription and # do a full subscribe again. sid, service = self._sid_and_service(service_or_sid) try: return await self._async_do_resubscribe(service, sid, timeout) except UpnpConnectionError as err: _LOGGER.debug( "Resubscribe for %s failed: %s. Device offline, not retrying.", service_or_sid, err, ) del self._subscriptions[sid] raise except UpnpError as err: _LOGGER.debug( "Resubscribe for %s failed: %s. Trying full subscribe.", service_or_sid, err, ) del self._subscriptions[sid] return await self.async_subscribe(service, timeout) async def async_resubscribe_all(self) -> None: """Renew all current subscription.""" await asyncio.gather( *(self.async_resubscribe(sid) for sid in self._subscriptions) ) async def async_unsubscribe( self, service_or_sid: Union[UpnpService, ServiceId], ) -> ServiceId: """Unsubscribe from a UpnpService.""" sid, service = self._sid_and_service(service_or_sid) _LOGGER.debug( "Unsubscribing from SID: %s, service: %s device: %s", sid, service, service.device, ) # Remove registration before potential device errors del self._subscriptions[sid] # do UNSUBSCRIBE request headers = { "HOST": urlparse(service.event_sub_url).netloc, "SID": sid, } request = HttpRequest("UNSUBSCRIBE", service.event_sub_url, headers, None) response = await self._requester.async_http_request(request) # check results if response.status_code != 200: _LOGGER.debug("Did not receive 200, but %s", response.status_code) raise UpnpResponseError( status=response.status_code, headers=response.headers ) return sid async def async_unsubscribe_all(self) -> None: """Unsubscribe all subscriptions.""" sids = list(self._subscriptions) await asyncio.gather( *(self.async_unsubscribe(sid) for sid in sids), return_exceptions=True, ) async def async_stop(self) -> None: """Stop event the UpnpNotifyServer.""" # This calls async_unsubscribe_all() via the notify server. await self._notify_server.async_stop_server() class UpnpEventHandlerRegister: """Event handler register to handle multiple interfaces.""" def __init__(self, requester: UpnpRequester, notify_server_type: Type) -> None: """Initialize.""" self.requester = requester self.notify_server_type = notify_server_type self._event_handlers: Dict[ IPvXAddress, Tuple[UpnpEventHandler, Set[UpnpDevice]] ] = {} def _get_event_handler_for_device( self, device: UpnpDevice ) -> Optional[UpnpEventHandler]: """Get the event handler for the device, if known.""" local_ip_str = get_local_ip(device.device_url) local_ip = ip_address(local_ip_str) if local_ip not in self._event_handlers: return None event_handler, devices = self._event_handlers[local_ip] if device in devices: return event_handler return None def has_event_handler_for_device(self, device: UpnpDevice) -> bool: """Check if an event handler for a device is already available.""" return self._get_event_handler_for_device(device) is not None async def async_add_device(self, device: UpnpDevice) -> UpnpEventHandler: """Add a new device, creates or gets the event handler for this device.""" local_ip_str = get_local_ip(device.device_url) local_ip = ip_address(local_ip_str) if local_ip not in self._event_handlers: event_handler = await self._create_event_handler_for_device(device) self._event_handlers[local_ip] = (event_handler, set([device])) return event_handler event_handler, devices = self._event_handlers[local_ip] devices.add(device) return event_handler async def _create_event_handler_for_device( self, device: UpnpDevice ) -> UpnpEventHandler: """Create a new event handler for a device.""" local_ip_str = get_local_ip(device.device_url) source_addr = (local_ip_str, 0) notify_server: UpnpNotifyServer = self.notify_server_type( requester=self.requester, source=source_addr ) await notify_server.async_start_server() return UpnpEventHandler(notify_server, self.requester) async def async_remove_device( self, device: UpnpDevice ) -> Optional[UpnpEventHandler]: """Remove an existing device, destroys the event handler and returns it, if needed.""" local_ip_str = get_local_ip(device.device_url) local_ip = ip_address(local_ip_str) assert local_ip in self._event_handlers event_handler, devices = self._event_handlers[local_ip] assert device in devices devices.remove(device) if not devices: await event_handler.async_stop() del self._event_handlers[local_ip] return event_handler return None async_upnp_client-0.44.0/async_upnp_client/exceptions.py000066400000000000000000000116251477256211100235770ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.exceptions module.""" import asyncio from enum import IntEnum from typing import Any, Optional from xml.etree import ElementTree as ET import aiohttp # pylint: disable=too-many-ancestors class UpnpError(Exception): """Base class for all errors raised by this library.""" def __init__( self, *args: Any, message: Optional[str] = None, **_kwargs: Any ) -> None: """Initialize base UpnpError.""" super().__init__(*args, message) class UpnpContentError(UpnpError): """Content of UPnP response is invalid.""" class UpnpActionErrorCode(IntEnum): """Error codes for UPnP Action errors.""" INVALID_ACTION = 401 INVALID_ARGS = 402 # (DO_NOT_USE) = 403 ACTION_FAILED = 501 ARGUMENT_VALUE_INVALID = 600 ARGUMENT_VALUE_OUT_OF_RANGE = 601 OPTIONAL_ACTION_NOT_IMPLEMENTED = 602 OUT_OF_MEMORY = 603 HUMAN_INTERVENTION_REQUIRED = 604 STRING_ARGUMENT_TOO_LONG = 605 class UpnpActionError(UpnpError): """Server returned a SOAP Fault in response to an Action.""" def __init__( self, *args: Any, error_code: Optional[int] = None, error_desc: Optional[str] = None, message: Optional[str] = None, **kwargs: Any, ) -> None: """Initialize from response body.""" if not message: message = f"Received UPnP error {error_code} ({error_desc})" super().__init__(*args, message=message, **kwargs) self.error_code = error_code self.error_desc = error_desc class UpnpXmlParseError(UpnpContentError, ET.ParseError): """UPnP response is not valid XML.""" def __init__(self, orig_err: ET.ParseError) -> None: """Initialize from original ParseError, to match it.""" super().__init__(message=str(orig_err)) self.code = orig_err.code self.position = orig_err.position class UpnpValueError(UpnpContentError): """Invalid value error.""" def __init__(self, name: str, value: Any) -> None: """Initialize.""" super().__init__(message=f"Invalid value for {name}: '{value}'") self.name = name self.value = value class UpnpSIDError(UpnpContentError): """Missing Subscription Identifier from response.""" class UpnpXmlContentError(UpnpContentError): """XML document does not have expected content.""" class UpnpCommunicationError(UpnpError, aiohttp.ClientError): """Error occurred while communicating with the UPnP device .""" class UpnpResponseError(UpnpCommunicationError): """HTTP error code returned by the UPnP device.""" def __init__( self, *args: Any, status: int, headers: Optional[aiohttp.typedefs.LooseHeaders] = None, message: Optional[str] = None, **kwargs: Any, ) -> None: """Initialize.""" if not message: message = f"Did not receive HTTP 200 but {status}" super().__init__(*args, message=message, **kwargs) self.status = status self.headers = headers class UpnpActionResponseError(UpnpActionError, UpnpResponseError): """HTTP error code and UPnP error code. UPnP errors are usually indicated with HTTP 500 (Internal Server Error) and actual details in the response body as a SOAP Fault. """ def __init__( # pylint: disable=too-many-arguments self, *args: Any, status: int, headers: Optional[aiohttp.typedefs.LooseHeaders] = None, error_code: Optional[int] = None, error_desc: Optional[str] = None, message: Optional[str] = None, **kwargs: Any, ) -> None: """Initialize.""" if not message: message = ( f"Received HTTP error code {status}, UPnP error code" f" {error_code} ({error_desc})" ) super().__init__( *args, status=status, headers=headers, error_code=error_code, error_desc=error_desc, message=message, **kwargs, ) class UpnpClientResponseError(aiohttp.ClientResponseError, UpnpResponseError): # type: ignore """HTTP response error with more details from aiohttp.""" class UpnpConnectionError(UpnpCommunicationError, aiohttp.ClientConnectionError): """Error in the underlying connection to the UPnP device. This could indicate that the device is offline. """ class UpnpConnectionTimeoutError( UpnpConnectionError, aiohttp.ServerTimeoutError, asyncio.TimeoutError ): """Timeout while communicating with the device.""" class UpnpServerError(UpnpError): """Error with a local server.""" class UpnpServerOSError(UpnpServerError, OSError): """System-related error when starting a local server.""" def __init___(self, errno: int, strerror: str) -> None: """Initialize simplified version of OSError.""" OSError.__init__(self, errno, strerror) async_upnp_client-0.44.0/async_upnp_client/profiles/000077500000000000000000000000001477256211100226625ustar00rootroot00000000000000async_upnp_client-0.44.0/async_upnp_client/profiles/__init__.py000066400000000000000000000001011477256211100247630ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.profiles module.""" async_upnp_client-0.44.0/async_upnp_client/profiles/dlna.py000066400000000000000000001525171477256211100241650ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.profiles.dlna module.""" # pylint: disable=too-many-lines import asyncio import logging from datetime import datetime, timedelta from enum import Enum, IntEnum, IntFlag from functools import lru_cache from http import HTTPStatus from mimetypes import guess_type from time import monotonic as monotonic_timer from typing import ( Any, Dict, Iterable, List, Mapping, MutableMapping, NamedTuple, Optional, Sequence, Set, Union, ) from xml.sax.handler import ContentHandler, ErrorHandler from xml.sax.xmlreader import AttributesImpl from defusedxml.sax import parseString from didl_lite import didl_lite from async_upnp_client.client import UpnpService, UpnpStateVariable from async_upnp_client.const import MIME_TO_UPNP_CLASS_MAPPING, HttpRequest from async_upnp_client.exceptions import UpnpError from async_upnp_client.profiles.profile import UpnpProfileDevice from async_upnp_client.utils import absolute_url, str_to_time, time_to_str _LOGGER = logging.getLogger(__name__) DeviceState = Enum("DeviceState", "ON PLAYING PAUSED IDLE") class TransportState(str, Enum): """Allowed values for DLNA AV Transport TransportState variable.""" STOPPED = "STOPPED" PLAYING = "PLAYING" TRANSITIONING = "TRANSITIONING" PAUSED_PLAYBACK = "PAUSED_PLAYBACK" PAUSED_RECORDING = "PAUSED_RECORDING" RECORDING = "RECORDING" NO_MEDIA_PRESENT = "NO_MEDIA_PRESENT" VENDOR_DEFINED = "VENDOR_DEFINED" class PlayMode(str, Enum): """Allowed values for DLNA AV Transport CurrentPlayMode variable.""" NORMAL = "NORMAL" SHUFFLE = "SHUFFLE" REPEAT_ONE = "REPEAT_ONE" REPEAT_ALL = "REPEAT_ALL" RANDOM = "RANDOM" DIRECT_1 = "DIRECT_1" INTRO = "INTRO" VENDOR_DEFINED = "VENDOR_DEFINED" class DlnaOrgOp(Enum): """DLNA.ORG_OP (Operations Parameter) flags.""" NONE = 0 RANGE = 0x01 TIMESEEK = 0x10 class DlnaOrgCi(Enum): """DLNA.ORG_CI (Conversion Indicator) flags.""" NONE = 0 TRANSCODED = 1 class DlnaOrgPs(Enum): """DLNA.ORG_PS (PlaySpeed ) flags.""" INVALID = 0 NORMAL = 1 class DlnaOrgFlags(IntFlag): """ DLNA.ORG_FLAGS flags. padded with 24 trailing 0s 80000000 31 sender paced 40000000 30 lsop time based seek supported 20000000 29 lsop byte based seek supported 10000000 28 playcontainer supported 8000000 27 s0 increasing supported 4000000 26 sN increasing supported 2000000 25 rtsp pause supported 1000000 24 streaming transfer mode supported 800000 23 interactive transfer mode supported 400000 22 background transfer mode supported 200000 21 connection stalling supported 100000 20 dlna version15 supported """ SENDER_PACED = 1 << 31 TIME_BASED_SEEK = 1 << 30 BYTE_BASED_SEEK = 1 << 29 PLAY_CONTAINER = 1 << 28 S0_INCREASE = 1 << 27 SN_INCREASE = 1 << 26 RTSP_PAUSE = 1 << 25 STREAMING_TRANSFER_MODE = 1 << 24 INTERACTIVE_TRANSFERT_MODE = 1 << 23 BACKGROUND_TRANSFERT_MODE = 1 << 22 CONNECTION_STALL = 1 << 21 DLNA_V15 = 1 << 20 class DlnaDmrEventContentHandler(ContentHandler): """Content Handler to parse DLNA DMR Event data.""" def __init__(self) -> None: """Initialize.""" super().__init__() self.changes: MutableMapping[str, MutableMapping[str, Any]] = {} self._current_instance: Optional[str] = None def startElement(self, name: str, attrs: AttributesImpl) -> None: """Handle startElement.""" if "val" not in attrs: return if name == "InstanceID": self._current_instance = attrs.get("val", "0") else: current_instance = self._current_instance or "0" # safety if current_instance not in self.changes: self.changes[current_instance] = {} # If channel is given, we're only interested in the Master channel. if attrs.get("channel") not in (None, "Master"): return # Strip namespace prefix. if ":" in name: index = name.find(":") + 1 name = name[index:] self.changes[current_instance][name] = attrs.get("val") def endElement(self, name: str) -> None: """Handle endElement.""" if name == "InstanceID": self._current_instance = None class DlnaDmrEventErrorHandler(ErrorHandler): """Error handler which ignores errors.""" def error(self, exception: BaseException) -> None: # type: ignore """Handle error.""" _LOGGER.debug("Error during parsing: %s", exception) def fatalError(self, exception: BaseException) -> None: # type: ignore """Handle error.""" _LOGGER.debug("Fatal error during parsing: %s", exception) def _parse_last_change_event(text: str) -> Mapping[str, Mapping[str, str]]: """ Parse a LastChange event. :param text Text to parse. :return Dict per Instance, containing changed state variables with values. """ content_handler = DlnaDmrEventContentHandler() error_handler = DlnaDmrEventErrorHandler() parseString(text.encode(), content_handler, error_handler) return content_handler.changes def dlna_handle_notify_last_change(state_var: UpnpStateVariable) -> None: """ Handle changes to LastChange state variable. This expands all changed state variables in the LastChange state variable. Note that the callback is called twice: - for the original event; - for the expanded event, via this function. """ if state_var.name != "LastChange": raise UpnpError("Call this only on state variable LastChange") event_data: Optional[str] = state_var.value if not event_data: _LOGGER.debug("No event data on state_variable") return changes = _parse_last_change_event(event_data) if "0" not in changes: _LOGGER.warning("Only InstanceID 0 is supported") return service = state_var.service changes_0 = changes["0"] service.notify_changed_state_variables(changes_0) @lru_cache(maxsize=128) def split_commas(input_: str) -> List[str]: """ Split a string into a list of comma separated values. Strip whitespace and omit the empty string. """ stripped = (item.strip() for item in input_.split(",")) return [item for item in stripped if item] @lru_cache(maxsize=128) def _lower_split_commas(input_: str) -> Set[str]: """Lowercase version of split_commas.""" return {a.lower() for a in split_commas(input_)} @lru_cache def _cached_from_xml_string( xml: str, ) -> List[Union[didl_lite.DidlObject, didl_lite.Descriptor]]: return didl_lite.from_xml_string(xml, strict=False) class ConnectionManagerMixin(UpnpProfileDevice): """Mix-in to support ConnectionManager actions and state variables.""" _SERVICE_TYPES = { "CM": { "urn:schemas-upnp-org:service:ConnectionManager:3", "urn:schemas-upnp-org:service:ConnectionManager:2", "urn:schemas-upnp-org:service:ConnectionManager:1", }, } __did_first_update: bool = False async def async_update(self) -> None: """Retrieve latest data.""" if not self.__did_first_update: await self._async_poll_state_variables("CM", "GetProtocolInfo") self.__did_first_update = True # region CM @property def has_get_protocol_info(self) -> bool: """Check if device can report its protocol info.""" return self._action("CM", "GetProtocolInfo") is not None async def async_get_protocol_info(self) -> Mapping[str, List[str]]: """Get protocol info.""" action = self._action("CM", "GetProtocolInfo") if not action: return {"source": [], "sink": []} protocol_info = await action.async_call() return { "source": split_commas(protocol_info["Source"]), "sink": split_commas(protocol_info["Sink"]), } @property def source_protocol_info(self) -> List[str]: """Supported source protocols.""" state_var = self._state_variable("CM", "SourceProtocolInfo") if state_var is None or not state_var.value: return [] return split_commas(state_var.value) @property def sink_protocol_info(self) -> List[str]: """Supported sink protocols.""" state_var = self._state_variable("CM", "SinkProtocolInfo") if state_var is None or not state_var.value: return [] return split_commas(state_var.value) # endregion class DmrDevice(ConnectionManagerMixin, UpnpProfileDevice): """Representation of a DLNA DMR device.""" # pylint: disable=too-many-public-methods DEVICE_TYPES = [ "urn:schemas-upnp-org:device:MediaRenderer:1", "urn:schemas-upnp-org:device:MediaRenderer:2", "urn:schemas-upnp-org:device:MediaRenderer:3", ] SERVICE_IDS = frozenset( ( "urn:upnp-org:serviceId:AVTransport", "urn:upnp-org:serviceId:ConnectionManager", "urn:upnp-org:serviceId:RenderingControl", ) ) _SERVICE_TYPES = { "RC": { "urn:schemas-upnp-org:service:RenderingControl:3", "urn:schemas-upnp-org:service:RenderingControl:2", "urn:schemas-upnp-org:service:RenderingControl:1", }, "AVT": { "urn:schemas-upnp-org:service:AVTransport:3", "urn:schemas-upnp-org:service:AVTransport:2", "urn:schemas-upnp-org:service:AVTransport:1", }, **ConnectionManagerMixin._SERVICE_TYPES, } _current_track_meta_data: Optional[didl_lite.DidlObject] = None _av_transport_uri_meta_data: Optional[didl_lite.DidlObject] = None __did_first_update: bool = False async def async_update(self, do_ping: bool = True) -> None: """Retrieve the latest data. :param do_ping: Poll device to check if it is available (online). """ # pylint: disable=arguments-differ await super().async_update() # call GetTransportInfo/GetPositionInfo regularly avt_service = self._service("AVT") if avt_service: if not self.is_subscribed or do_ping: # CurrentTransportState is evented, so don't need to poll when subscribed await self._async_poll_state_variables( "AVT", "GetTransportInfo", InstanceID=0 ) if self.transport_state in ( TransportState.PLAYING, TransportState.PAUSED_PLAYBACK, ): # playing something, get position info # RelativeTimePosition is *never* evented, must always poll await self._async_poll_state_variables( "AVT", "GetPositionInfo", InstanceID=0 ) if not self.is_subscribed or not self.__did_first_update: # Events won't be sent, so poll all state variables await self._async_poll_state_variables( "AVT", [ "GetMediaInfo", "GetDeviceCapabilities", "GetTransportSettings", "GetCurrentTransportActions", ], InstanceID=0, ) await self._async_poll_state_variables( "RC", ["GetMute", "GetVolume"], InstanceID=0, Channel="Master" ) self.__did_first_update = True elif do_ping: await self.profile_device.async_ping() def _on_event( self, service: UpnpService, state_variables: Sequence[UpnpStateVariable] ) -> None: """State variable(s) changed, perform callback(s).""" # handle DLNA specific event for state_variable in state_variables: if state_variable.name == "LastChange": dlna_handle_notify_last_change(state_variable) if service.service_id == "urn:upnp-org:serviceId:AVTransport": for state_variable in state_variables: if state_variable.name == "CurrentTrackMetaData": self._update_current_track_meta_data(state_variable) if state_variable.name == "AVTransportURIMetaData": self._update_av_transport_uri_metadata(state_variable) if self.on_event: # pylint: disable=not-callable self.on_event(service, state_variables) @property def state(self) -> DeviceState: """ Get current state. This property is deprecated and will be removed in a future version! Please use `transport_state` instead. """ state_var = self._state_variable("AVT", "TransportState") if not state_var: return DeviceState.ON state_value = (state_var.value or "").strip().lower() if state_value == "playing": return DeviceState.PLAYING if state_value in ("paused", "paused_playback"): return DeviceState.PAUSED return DeviceState.IDLE @property def transport_state(self) -> Optional[TransportState]: """Get transport state.""" state_var = self._state_variable("AVT", "TransportState") if not state_var: return None state_value = (state_var.value or "").strip().upper() try: return TransportState[state_value] except KeyError: # Unknown state; return VENDOR_DEFINED. return TransportState.VENDOR_DEFINED @property def _has_current_transport_actions(self) -> bool: state_var = self._state_variable("AVT", "CurrentTransportActions") if not state_var: return False return state_var.value is not None or state_var.updated_at is not None @property def _current_transport_actions(self) -> Set[str]: state_var = self._state_variable("AVT", "CurrentTransportActions") if not state_var: return set() return _lower_split_commas(state_var.value or "") def _can_transport_action(self, action: str) -> bool: current_transport_actions = self._current_transport_actions return not current_transport_actions or action in current_transport_actions def _supports(self, var_name: str) -> bool: return ( self._state_variable("RC", var_name) is not None and self._action("RC", f"Set{var_name}") is not None ) def _level(self, var_name: str) -> Optional[float]: state_var = self._state_variable("RC", var_name) if state_var is None: _LOGGER.debug("Missing StateVariable RC/%s", var_name) return None value: Optional[float] = state_var.value if value is None: _LOGGER.debug("Got no value for %s", var_name) return None max_value = state_var.max_value or 100.0 return min(value / max_value, 1.0) async def _async_set_level( self, var_name: str, level: float, **kwargs: Any ) -> None: action = self._action("RC", f"Set{var_name}") if not action: raise UpnpError(f"Missing Action RC/Set{var_name}") arg_name = f"Desired{var_name}" argument = action.argument(arg_name) if not argument: raise UpnpError(f"Missing Argument {arg_name} for Action RC/Set{var_name}") state_variable = argument.related_state_variable min_ = state_variable.min_value or 0 max_ = state_variable.max_value or 100 desired_level = int(min_ + level * (max_ - min_)) args = kwargs.copy() args[arg_name] = desired_level await action.async_call(InstanceID=0, **args) # region RC/Picture @property def has_brightness_level(self) -> bool: """Check if device has brightness level controls.""" return self._supports("Brightness") @property def brightness_level(self) -> Optional[float]: """Brightness level of the media player (0..1).""" return self._level("Brightness") async def async_set_brightness_level(self, brightness: float) -> None: """Set brightness level, range 0..1.""" await self._async_set_level("Brightness", brightness) @property def has_contrast_level(self) -> bool: """Check if device has contrast level controls.""" return self._supports("Contrast") @property def contrast_level(self) -> Optional[float]: """Contrast level of the media player (0..1).""" return self._level("Contrast") async def async_set_contrast_level(self, contrast: float) -> None: """Set contrast level, range 0..1.""" await self._async_set_level("Contrast", contrast) @property def has_sharpness_level(self) -> bool: """Check if device has sharpness level controls.""" return self._supports("Sharpness") @property def sharpness_level(self) -> Optional[float]: """Sharpness level of the media player (0..1).""" return self._level("Sharpness") async def async_set_sharpness_level(self, sharpness: float) -> None: """Set sharpness level, range 0..1.""" await self._async_set_level("Sharpness", sharpness) @property def has_color_temperature_level(self) -> bool: """Check if device has color temperature level controls.""" return self._supports("ColorTemperature") @property def color_temperature_level(self) -> Optional[float]: """Color temperature level of the media player (0..1).""" return self._level("ColorTemperature") async def async_set_color_temperature_level(self, color_temperature: float) -> None: """Set color temperature level, range 0..1.""" # pylint: disable=invalid-name await self._async_set_level("ColorTemperature", color_temperature) # endregion # region RC/Volume @property def has_volume_level(self) -> bool: """Check if device has Volume level controls.""" return self._supports("Volume") @property def volume_level(self) -> Optional[float]: """Volume level of the media player (0..1).""" return self._level("Volume") async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._async_set_level("Volume", volume, Channel="Master") @property def has_volume_mute(self) -> bool: """Check if device has Volume mute controls.""" return self._supports("Mute") @property def is_volume_muted(self) -> Optional[bool]: """Boolean if volume is currently muted.""" state_var = self._state_variable("RC", "Mute") if not state_var: return None value: Optional[bool] = state_var.value if value is None: _LOGGER.debug("Got no value for Volume_mute") return None return value async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" action = self._action("RC", "SetMute") if not action: raise UpnpError("Missing action RC/SetMute") desired_mute = bool(mute) await action.async_call( InstanceID=0, Channel="Master", DesiredMute=desired_mute ) # endregion # region RC/Preset @property def has_presets(self) -> bool: """Check if device has control for rendering presets.""" return ( self._state_variable("RC", "PresetNameList") is not None and self._action("RC", "SelectPreset") is not None ) @property def preset_names(self) -> List[str]: """List of valid preset names.""" state_var = self._state_variable("RC", "PresetNameList") if state_var is None: _LOGGER.debug("Missing StateVariable RC/PresetNameList") return [] value: Optional[str] = state_var.value if value is None: _LOGGER.debug("Got no value for PresetNameList") return [] return split_commas(value) async def async_select_preset(self, preset_name: str) -> None: """Send SelectPreset command.""" action = self._action("RC", "SelectPreset") if not action: raise UpnpError("Missing action RC/SelectPreset") await action.async_call(InstanceID=0, PresetName=preset_name) # endregion # region AVT/Transport actions @property def has_pause(self) -> bool: """Check if device has Pause controls.""" return self._action("AVT", "Pause") is not None @property def can_pause(self) -> bool: """Check if the device can currently Pause.""" return self.has_pause and self._can_transport_action("pause") async def async_pause(self) -> None: """Send pause command.""" if not self._can_transport_action("pause"): _LOGGER.debug("Cannot do Pause") return action = self._action("AVT", "Pause") if not action: raise UpnpError("Missing action AVT/Pause") await action.async_call(InstanceID=0) @property def has_play(self) -> bool: """Check if device has Play controls.""" return self._action("AVT", "Play") is not None @property def can_play(self) -> bool: """Check if the device can currently play.""" return self.has_play and self._can_transport_action("play") async def async_play(self) -> None: """Send play command.""" if not self._can_transport_action("play"): _LOGGER.debug("Cannot do Play") return action = self._action("AVT", "Play") if not action: raise UpnpError("Missing action AVT/Play") await action.async_call(InstanceID=0, Speed="1") @property def can_stop(self) -> bool: """Check if the device can currently stop.""" return self.has_stop and self._can_transport_action("stop") @property def has_stop(self) -> bool: """Check if device has Play controls.""" return self._action("AVT", "Stop") is not None async def async_stop(self) -> None: """Send stop command.""" if not self._can_transport_action("stop"): _LOGGER.debug("Cannot do Stop") return action = self._action("AVT", "Stop") if not action: raise UpnpError("Missing action AVT/Stop") await action.async_call(InstanceID=0) @property def has_previous(self) -> bool: """Check if device has Previous controls.""" return self._action("AVT", "Previous") is not None @property def can_previous(self) -> bool: """Check if the device can currently Previous.""" return self.has_previous and self._can_transport_action("previous") async def async_previous(self) -> None: """Send previous track command.""" if not self._can_transport_action("previous"): _LOGGER.debug("Cannot do Previous") return action = self._action("AVT", "Previous") if not action: raise UpnpError("Missing action AVT/Previous") await action.async_call(InstanceID=0) @property def has_next(self) -> bool: """Check if device has Next controls.""" return self._action("AVT", "Next") is not None @property def can_next(self) -> bool: """Check if the device can currently Next.""" return self.has_next and self._can_transport_action("next") async def async_next(self) -> None: """Send next track command.""" if not self._can_transport_action("next"): _LOGGER.debug("Cannot do Next") return action = self._action("AVT", "Next") if not action: raise UpnpError("Missing action AVT/Next") await action.async_call(InstanceID=0) def _has_seek_with_mode(self, mode: str) -> bool: """Check if device has Seek mode.""" action = self._action("AVT", "Seek") state_var = self._state_variable("AVT", "A_ARG_TYPE_SeekMode") if action is None or state_var is None: return False return mode.lower() in state_var.normalized_allowed_values @property def has_seek_abs_time(self) -> bool: """Check if device has Seek controls, by ABS_TIME.""" return self._has_seek_with_mode("ABS_TIME") @property def can_seek_abs_time(self) -> bool: """Check if the device can currently Seek with ABS_TIME.""" return self.has_seek_abs_time and self._can_transport_action("seek") async def async_seek_abs_time(self, time: timedelta) -> None: """Send seek command with ABS_TIME.""" if not self._can_transport_action("seek"): _LOGGER.debug("Cannot do Seek by ABS_TIME") return action = self._action("AVT", "Seek") if not action: raise UpnpError("Missing action AVT/Seek") target = time_to_str(time) await action.async_call(InstanceID=0, Unit="ABS_TIME", Target=target) @property def has_seek_rel_time(self) -> bool: """Check if device has Seek controls, by REL_TIME.""" return self._has_seek_with_mode("REL_TIME") @property def can_seek_rel_time(self) -> bool: """Check if the device can currently Seek with REL_TIME.""" return self.has_seek_rel_time and self._can_transport_action("seek") async def async_seek_rel_time(self, time: timedelta) -> None: """Send seek command with REL_TIME.""" if not self._can_transport_action("seek"): _LOGGER.debug("Cannot do Seek by REL_TIME") return action = self._action("AVT", "Seek") if not action: raise UpnpError("Missing action AVT/Seek") target = time_to_str(time) await action.async_call(InstanceID=0, Unit="REL_TIME", Target=target) @property def has_play_media(self) -> bool: """Check if device has Play controls.""" return self._action("AVT", "SetAVTransportURI") is not None @property def current_track_uri(self) -> Optional[str]: """Return the URI of the currently playing track.""" state_var = self._state_variable("AVT", "CurrentTrackURI") if state_var is None: _LOGGER.debug("Missing StateVariable AVT/CurrentTrackURI") return None return state_var.value @property def av_transport_uri(self) -> Optional[str]: """Return the URI of the currently playing resource (playlist or track).""" state_var = self._state_variable("AVT", "AVTransportURI") if state_var is None: _LOGGER.debug("Missing StateVariable AVT/AVTransportURI") return None return state_var.value async def async_set_transport_uri( self, media_url: str, media_title: str, meta_data: Union[None, str, Mapping] = None, ) -> None: """Play a piece of media.""" # escape media_url _LOGGER.debug("Set transport uri: %s", media_url) # queue media if not isinstance(meta_data, str): meta_data = await self.construct_play_media_metadata( media_url, media_title, meta_data=meta_data ) action = self._action("AVT", "SetAVTransportURI") if not action: raise UpnpError("Missing action AVT/SetAVTransportURI") await action.async_call( InstanceID=0, CurrentURI=media_url, CurrentURIMetaData=meta_data ) @property def has_next_transport_uri(self) -> bool: """Check if device has controls to set the next item for playback.""" return ( self._state_variable("AVT", "NextAVTransportURI") is not None and self._action("AVT", "SetNextAVTransportURI") is not None ) async def async_set_next_transport_uri( self, media_url: str, media_title: str, meta_data: Union[None, str, Mapping] = None, ) -> None: """Enqueue a piece of media for playing immediately after the current media.""" # escape media_url _LOGGER.debug("Set next transport uri: %s", media_url) # queue media if not isinstance(meta_data, str): meta_data = await self.construct_play_media_metadata( media_url, media_title, meta_data=meta_data ) action = self._action("AVT", "SetNextAVTransportURI") if not action: raise UpnpError("Missing action AVT/SetNextAVTransportURI") await action.async_call( InstanceID=0, NextURI=media_url, NextURIMetaData=meta_data ) async def async_wait_for_can_play(self, max_wait_time: float = 5) -> None: """Wait for play command to be ready.""" loop_time = 0.25 end_time = monotonic_timer() + max_wait_time while monotonic_timer() <= end_time: if self._can_transport_action("play"): break await asyncio.sleep(loop_time) # Check again before trying to poll, in case variable change event received if self._can_transport_action("play"): break # Poll current transport actions, even if we're subscribed, just in # case the device isn't eventing properly. await self._async_poll_state_variables( "AVT", "GetCurrentTransportActions", InstanceID=0 ) else: _LOGGER.debug("break out of waiting game") async def _fetch_headers( self, url: str, headers: Mapping[str, str] ) -> Optional[Mapping[str, str]]: """Do a HEAD/GET to get resources headers.""" requester = self.profile_device.requester # try a HEAD first request = HttpRequest("HEAD", url, headers, None) response = await requester.async_http_request(request) if 200 <= response.status_code < 300: return response.headers if response.status_code == HTTPStatus.NOT_FOUND: # Give up when the item doesn't exist, otherwise try GET below return None # then try a GET request for only the first byte of content get_headers = dict(headers) get_headers["Range"] = "bytes=0-0" request = HttpRequest("GET", url, get_headers, None) response = await requester.async_http_request(request) if 200 <= response.status_code < 300: return response.headers # finally try a plain GET, which might return a lot of data request = HttpRequest("GET", url, headers, None) response = await requester.async_http_request(request) if 200 <= response.status_code < 300: return response.headers return None async def construct_play_media_metadata( self, media_url: str, media_title: str, default_mime_type: Optional[str] = None, default_upnp_class: Optional[str] = None, override_mime_type: Optional[str] = None, override_upnp_class: Optional[str] = None, override_dlna_features: Optional[str] = None, meta_data: Optional[Mapping[str, Any]] = None, ) -> str: """ Construct the metadata for play_media command. This queries the source and takes mime_type/dlna_features from it. The base metadata is updated with key:values from meta_data, e.g. `meta_data = {"artist": "Singer X"}` """ # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals, too-many-branches mime_type = override_mime_type or "" upnp_class = override_upnp_class or "" dlna_features = override_dlna_features or "*" meta_data = meta_data or {} if None in (override_mime_type, override_dlna_features): # do a HEAD/GET, to retrieve content-type/mime-type try: headers = await self._fetch_headers( media_url, {"GetContentFeatures.dlna.org": "1"} ) if headers: if not override_mime_type and "Content-Type" in headers: mime_type = headers["Content-Type"] if ( not override_dlna_features and "ContentFeatures.dlna.org" in headers ): dlna_features = headers["ContentFeatures.dlna.org"] except Exception: # pylint: disable=broad-except pass if not mime_type: _type = guess_type(media_url.split("?")[0]) mime_type = _type[0] or "" if not mime_type: mime_type = default_mime_type or "application/octet-stream" # use CM/GetProtocolInfo to improve on dlna_features if ( not override_dlna_features and dlna_features != "*" and self.has_get_protocol_info ): protocol_info_entries = ( await self._async_get_sink_protocol_info_for_mime_type(mime_type) ) for entry in protocol_info_entries: if entry[3] == "*": # device accepts anything, send this dlna_features = "*" # Try to derive a basic upnp_class from mime_type if not override_upnp_class: mime_type = mime_type.lower() for _mime, _class in MIME_TO_UPNP_CLASS_MAPPING.items(): if mime_type.startswith(_mime): upnp_class = _class break else: upnp_class = default_upnp_class or "object.item" # build DIDL-Lite item + resource didl_item_type = didl_lite.type_by_upnp_class(upnp_class) if not didl_item_type: raise UpnpError("Unknown DIDL-lite type") protocol_info = f"http-get:*:{mime_type}:{dlna_features}" resource = didl_lite.Resource(uri=media_url, protocol_info=protocol_info) item = didl_item_type( id="0", parent_id="-1", title=media_title or meta_data.get("title"), restricted="false", resources=[resource], ) # Set any metadata properties that are supported by the DIDL item for key, value in meta_data.items(): setattr(item, key, str(value)) xml_string: bytes = didl_lite.to_xml_string(item) return xml_string.decode("utf-8") async def _async_get_sink_protocol_info_for_mime_type( self, mime_type: str ) -> List[List[str]]: """Get protocol_info for a specific mime type.""" # example entry: # http-get:*:video/mpeg:DLNA.ORG_PN=MPEG_TS_HD_KO_ISO;DLNA.ORG_FLAGS=ED100000000000000000... return [ entry.split(":") for entry in self.source_protocol_info if ":" in entry and entry.split(":")[2] == mime_type ] # endregion # region: AVT/PlayMode @property def has_play_mode(self) -> bool: """Check if device supports setting the play mode.""" return ( self._state_variable("AVT", "CurrentPlayMode") is not None and self._action("AVT", "SetPlayMode") is not None ) @property def valid_play_modes(self) -> Set[PlayMode]: """Return a set of play modes that can be used.""" play_modes: Set[PlayMode] = set() state_var = self._state_variable("AVT", "CurrentPlayMode") if state_var is None: return play_modes for normalized_allowed_value in state_var.normalized_allowed_values: try: mode = PlayMode[normalized_allowed_value.upper()] except KeyError: # Unknown mode, don't report it as valid continue play_modes.add(mode) return play_modes @property def play_mode(self) -> Optional[PlayMode]: """Get play mode.""" state_var = self._state_variable("AVT", "CurrentPlayMode") if not state_var: return None state_value = (state_var.value or "").strip().upper() try: return PlayMode[state_value] except KeyError: # Unknown mode; return VENDOR_DEFINED. return PlayMode.VENDOR_DEFINED async def async_set_play_mode(self, mode: PlayMode) -> None: """Send SetPlayMode command.""" action = self._action("AVT", "SetPlayMode") if not action: raise UpnpError("Missing action AVT/SetPlayMode") await action.async_call(InstanceID=0, NewPlayMode=mode.name) # endregion # region AVT/Media info def _update_current_track_meta_data(self, state_var: UpnpStateVariable) -> None: """Update the cached parsed value of AVT/CurrentTrackMetaData.""" xml = state_var.value if not xml or xml == "NOT_IMPLEMENTED": self._current_track_meta_data = None return items = _cached_from_xml_string(xml) if not items: self._current_track_meta_data = None return item = items[0] if not isinstance(item, didl_lite.DidlObject): self._current_track_meta_data = None return self._current_track_meta_data = item def _get_current_track_meta_data(self, attr: str) -> Optional[str]: """Return a metadata attribute if it exists, None otherwise.""" if not self._current_track_meta_data: return None if not hasattr(self._current_track_meta_data, attr): return None value: str = getattr(self._current_track_meta_data, attr) return value @property def media_class(self) -> Optional[str]: """DIDL-Lite class of currently playing media.""" if not self._current_track_meta_data: return None media_class: str = self._current_track_meta_data.upnp_class return media_class @property def media_title(self) -> Optional[str]: """Title of current playing media.""" return self._get_current_track_meta_data("title") @property def media_program_title(self) -> Optional[str]: """Title of current playing media.""" return self._get_current_track_meta_data("program_title") @property def media_artist(self) -> Optional[str]: """Artist of current playing media.""" return self._get_current_track_meta_data("artist") @property def media_album_name(self) -> Optional[str]: """Album name of current playing media.""" return self._get_current_track_meta_data("album") @property def media_album_artist(self) -> Optional[str]: """Album artist of current playing media.""" return self._get_current_track_meta_data("album_artist") @property def media_track_number(self) -> Optional[int]: """Track number of current playing media.""" state_var = self._state_variable("AVT", "CurrentTrack") if state_var is None: _LOGGER.debug("Missing StateVariable AVT/CurrentTrack") return None value: Optional[int] = state_var.value return value @property def media_series_title(self) -> Optional[str]: """Title of series of currently playing media.""" return self._get_current_track_meta_data("series_title") @property def media_season_number(self) -> Optional[str]: """Season of series of currently playing media.""" return self._get_current_track_meta_data("episode_season") @property def media_episode_number(self) -> Optional[str]: """Episode number, within the series, of current playing media. Note: This is usually the absolute number, starting at 1, of the episode within the *series* and not the *season*. """ return self._get_current_track_meta_data("episode_number") @property def media_episode_count(self) -> Optional[str]: """Total number of episodes in series to which currently playing media belongs.""" return self._get_current_track_meta_data("episode_count") @property def media_channel_name(self) -> Optional[str]: """Name of currently playing channel.""" return self._get_current_track_meta_data("channel_name") @property def media_channel_number(self) -> Optional[str]: """Channel number of currently playing channel.""" return self._get_current_track_meta_data("channel_number") @property def media_image_url(self) -> Optional[str]: """Image url of current playing media.""" state_var = self._state_variable("AVT", "CurrentTrackMetaData") if state_var is None: return None xml = state_var.value if not xml or xml == "NOT_IMPLEMENTED": return None items = _cached_from_xml_string(xml) if not items: return None device_url = self.profile_device.device_url for item in items: # Some players use Item.albumArtURI, # though not found in the UPnP-av-ConnectionManager-v1-Service spec. if hasattr(item, "album_art_uri") and item.album_art_uri is not None: return absolute_url(device_url, item.album_art_uri) for res in item.resources: protocol_info = res.protocol_info or "" if protocol_info.startswith("http-get:*:image/") and res.uri: return absolute_url(device_url, res.uri) return None @property def media_duration(self) -> Optional[int]: """Duration of current playing media in seconds.""" state_var = self._state_variable("AVT", "CurrentTrackDuration") if ( state_var is None or state_var.value is None or state_var.value == "NOT_IMPLEMENTED" ): return None time = str_to_time(state_var.value) if time is None: return None return time.seconds @property def media_position(self) -> Optional[int]: """Position of current playing media in seconds.""" state_var = self._state_variable("AVT", "RelativeTimePosition") if ( state_var is None or state_var.value is None or state_var.value == "NOT_IMPLEMENTED" ): return None time = str_to_time(state_var.value) if time is None: return None return time.seconds @property def media_position_updated_at(self) -> Optional[datetime]: """When was the position of the current playing media valid.""" state_var = self._state_variable("AVT", "RelativeTimePosition") if state_var is None: return None return state_var.updated_at # endregion # region AVT/Playlist info def _update_av_transport_uri_metadata(self, state_var: UpnpStateVariable) -> None: """Update the cached parsed value of AVT/AVTransportURIMetaData.""" xml = state_var.value if not xml or xml == "NOT_IMPLEMENTED": self._av_transport_uri_meta_data = None return items = _cached_from_xml_string(xml) if not items: self._av_transport_uri_meta_data = None return item = items[0] if not isinstance(item, didl_lite.DidlObject): self._av_transport_uri_meta_data = None return self._av_transport_uri_meta_data = item def _get_av_transport_meta_data(self, attr: str) -> Optional[str]: """Return an attribute of AVTransportURIMetaData if it exists, None otherwise.""" if not self._av_transport_uri_meta_data: return None if not hasattr(self._av_transport_uri_meta_data, attr): return None value: str = getattr(self._av_transport_uri_meta_data, attr) return value @property def media_playlist_title(self) -> Optional[str]: """Title of currently playing playlist, if a playlist is playing.""" if self.av_transport_uri == self.current_track_uri: # A single track is playing, no playlist to report return None return self._get_av_transport_meta_data("title") # endregion class DmsDevice(ConnectionManagerMixin, UpnpProfileDevice): """Representation of a DLNA DMS device.""" DEVICE_TYPES = [ "urn:schemas-upnp-org:device:MediaServer:1", "urn:schemas-upnp-org:device:MediaServer:2", "urn:schemas-upnp-org:device:MediaServer:3", "urn:schemas-upnp-org:device:MediaServer:4", ] SERVICE_IDS = frozenset( ( "urn:upnp-org:serviceId:ConnectionManager", "urn:upnp-org:serviceId:ContentDirectory", ) ) _SERVICE_TYPES = { "CD": { "urn:schemas-upnp-org:service:ContentDirectory:4", "urn:schemas-upnp-org:service:ContentDirectory:3", "urn:schemas-upnp-org:service:ContentDirectory:2", "urn:schemas-upnp-org:service:ContentDirectory:1", }, **ConnectionManagerMixin._SERVICE_TYPES, } METADATA_FILTER_ALL = "*" DEFAULT_METADATA_FILTER = METADATA_FILTER_ALL DEFAULT_SORT_CRITERIA = "" __did_first_update: bool = False async def async_update(self, do_ping: bool = False) -> None: """Retrieve the latest data.""" # pylint: disable=arguments-differ await super().async_update() # Retrieve unevented changeable values if not self.is_subscribed or not self.__did_first_update: await self._async_poll_state_variables("CD", "GetSystemUpdateID") elif do_ping: await self.profile_device.async_ping() # Retrieve unchanging state variables only once if not self.__did_first_update: await self._async_poll_state_variables( "CD", ["GetSearchCapabilities", "GetSortCapabilities"] ) self.__did_first_update = True def get_absolute_url(self, url: str) -> str: """Resolve a URL returned by the device into an absolute URL.""" return absolute_url(self.device.device_url, url) # region CD @property def search_capabilities(self) -> List[str]: """List of capabilities that are supported for search.""" state_var = self._state_variable("CD", "SearchCapabilities") if state_var is None or state_var.value is None: return [] return split_commas(state_var.value) @property def sort_capabilities(self) -> List[str]: """List of meta-data tags that can be used in sort_criteria.""" state_var = self._state_variable("CD", "SortCapabilities") if state_var is None or state_var.value is None: return [] return split_commas(state_var.value) @property def system_update_id(self) -> Optional[int]: """Return the latest update SystemUpdateID. Changes to this ID indicate that changes have occurred in the Content Directory. """ state_var = self._state_variable("CD", "SystemUpdateID") if state_var is None or state_var.value is None: return None return int(state_var.value) @property def has_container_update_ids(self) -> bool: """Check if device supports the ContainerUpdateIDs variable.""" return self._action("CD", "ContainerUpdateIDs") is not None @property def container_update_ids(self) -> Optional[Dict[str, int]]: """Return latest list of changed containers. This variable is evented only, and optional. If it's None, use the system_update_id to track container changes instead. :return: Mapping of container IDs to container update IDs """ state_var = self._state_variable("CD", "ContainerUpdateIDs") if state_var is None or state_var.value is None: return None # Convert list of containerID,updateID,containerID,updateID pairs to dict id_list = split_commas(state_var.value) return {id_list[i]: int(id_list[i + 1]) for i in range(0, len(id_list), 2)} class BrowseResult(NamedTuple): """Result returned from a Browse or Search action.""" result: List[Union[didl_lite.DidlObject, didl_lite.Descriptor]] number_returned: int total_matches: int update_id: int async def async_browse( self, object_id: str, browse_flag: str, metadata_filter: Union[Iterable[str], str] = DEFAULT_METADATA_FILTER, starting_index: int = 0, requested_count: int = 0, sort_criteria: Union[Iterable[str], str] = DEFAULT_SORT_CRITERIA, ) -> BrowseResult: """Retrieve an object's metadata or its children.""" # pylint: disable=too-many-arguments,too-many-positional-arguments action = self._action("CD", "Browse") if not action: raise UpnpError("Missing action CD/Browse") if not isinstance(metadata_filter, str): metadata_filter = ",".join(metadata_filter) if not isinstance(sort_criteria, str): sort_criteria = ",".join(sort_criteria) result = await action.async_call( ObjectID=object_id, BrowseFlag=browse_flag, Filter=metadata_filter, StartingIndex=starting_index, RequestedCount=requested_count, SortCriteria=sort_criteria, ) return DmsDevice.BrowseResult( didl_lite.from_xml_string(result["Result"], strict=False), int(result["NumberReturned"]), int(result["TotalMatches"]), int(result["UpdateID"]), ) async def async_browse_metadata( self, object_id: str, metadata_filter: Union[Iterable[str], str] = DEFAULT_METADATA_FILTER, ) -> didl_lite.DidlObject: """Get the metadata (properties) of an object.""" _LOGGER.debug("browse_metadata(%r, %r)", object_id, metadata_filter) result = await self.async_browse( object_id, "BrowseMetadata", metadata_filter, ) metadata = result.result[0] assert isinstance(metadata, didl_lite.DidlObject) _LOGGER.debug("browse_metadata -> %r", metadata) return metadata async def async_browse_direct_children( self, object_id: str, metadata_filter: Union[Iterable[str], str] = DEFAULT_METADATA_FILTER, starting_index: int = 0, requested_count: int = 0, sort_criteria: Union[Iterable[str], str] = DEFAULT_SORT_CRITERIA, ) -> BrowseResult: """Get the direct children of an object.""" # pylint: disable=too-many-arguments,too-many-positional-arguments _LOGGER.debug("browse_direct_children(%r, %r)", object_id, metadata_filter) result = await self.async_browse( object_id, "BrowseDirectChildren", metadata_filter, starting_index, requested_count, sort_criteria, ) _LOGGER.debug("browse_direct_children -> %r", result) return result @property def has_search_directory(self) -> bool: """Check if device supports the Search action.""" return self._action("CD", "Search") is not None async def async_search_directory( self, container_id: str, search_criteria: str, metadata_filter: Union[Iterable[str], str] = DEFAULT_METADATA_FILTER, starting_index: int = 0, requested_count: int = 0, sort_criteria: Union[Iterable[str], str] = DEFAULT_SORT_CRITERIA, ) -> BrowseResult: """Search ContentDirectory for objects that match some criteria. NOTE: This is not UpnpProfileDevice.async_search, which searches for matching UPnP devices. """ # pylint: disable=too-many-arguments,too-many-positional-arguments _LOGGER.debug( "search_directory(%r, %r, %r)", container_id, search_criteria, metadata_filter, ) action = self._action("CD", "Search") if not action: raise UpnpError("Missing action CD/Search") if not isinstance(metadata_filter, str): metadata_filter = ",".join(metadata_filter) if not isinstance(sort_criteria, str): sort_criteria = ",".join(sort_criteria) result = await action.async_call( ContainerID=container_id, SearchCriteria=search_criteria, Filter=metadata_filter, StartingIndex=starting_index, RequestedCount=requested_count, SortCriteria=sort_criteria, ) browse_result = DmsDevice.BrowseResult( didl_lite.from_xml_string(result["Result"], strict=False), int(result["NumberReturned"]), int(result["TotalMatches"]), int(result["UpdateID"]), ) _LOGGER.debug("search_directory -> %r", browse_result) return browse_result # endregion class ContentDirectoryErrorCode(IntEnum): """Error codes specific to DLNA Content Directory actions.""" NO_SUCH_OBJECT = 701 INVALID_CURRENT_TAG_VALUE = 702 INVALID_NEW_TAG_VALUE = 703 REQUIRED_TAG = 704 READ_ONLY_TAG = 705 PARAMETER_MISMATCH = 706 INVALID_SEARCH_CRITERIA = 708 INVALID_SORT_CRITERIA = 709 NO_SUCH_CONTAINER = 710 RESTRICTED_OJECT = 711 BAD_METADATA = 712 RESTRICTED_PARENT_OBJECT = 713 NO_SUCH_SOURCE_RESOURCES = 714 SOURCE_RESOURCE_ACCESS_DENIED = 715 TRANSFER_BUSY = 716 NO_SUCH_FILE_TRANSFER = 717 NO_SUCH_DESTINATION_SOURCE = 718 DESTINATION_RESOURCE_ACCESS_DENIED = 719 CANNOT_PROCESS_REQUEST = 720 async_upnp_client-0.44.0/async_upnp_client/profiles/igd.py000066400000000000000000001007511477256211100240030ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.profiles.igd module.""" import asyncio import logging from datetime import datetime, timedelta from enum import Enum from ipaddress import IPv4Address, IPv6Address from typing import List, NamedTuple, Optional, Sequence, Set, Union, cast from async_upnp_client.client import UpnpAction, UpnpDevice, UpnpStateVariable from async_upnp_client.event_handler import UpnpEventHandler from async_upnp_client.profiles.profile import UpnpProfileDevice TIMESTAMP = "timestamp" BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" PACKETS_SENT = "packets_sent" KIBIBYTES_PER_SEC_RECEIVED = "kibytes_sec_received" KIBIBYTES_PER_SEC_SENT = "kibytes_sec_sent" PACKETS_SEC_RECEIVED = "packets_sec_received" PACKETS_SEC_SENT = "packets_sec_sent" STATUS_INFO = "status_info" EXTERNAL_IP_ADDRESS = "external_ip_address" _LOGGER = logging.getLogger(__name__) class CommonLinkProperties(NamedTuple): """Common link properties.""" wan_access_type: str layer1_upstream_max_bit_rate: int layer1_downstream_max_bit_rate: int physical_link_status: str class ConnectionTypeInfo(NamedTuple): """Connection type info.""" connection_type: str possible_connection_types: str class StatusInfo(NamedTuple): """Status info.""" connection_status: str last_connection_error: str uptime: int class NatRsipStatusInfo(NamedTuple): """NAT RSIP status info.""" nat_enabled: bool rsip_available: bool class PortMappingEntry(NamedTuple): """Port mapping entry.""" remote_host: Optional[IPv4Address] external_port: int protocol: str internal_port: int internal_client: IPv4Address enabled: bool description: str lease_duration: Optional[timedelta] class FirewallStatus(NamedTuple): """IPv6 Firewall status.""" firewall_enabled: bool inbound_pinhole_allowed: bool class Pinhole(NamedTuple): """IPv6 Pinhole.""" remote_host: str remote_port: int internal_client: str internal_port: int protocol: int lease_time: int class TrafficCounterState(NamedTuple): """Traffic state.""" timestamp: datetime bytes_received: Union[None, BaseException, int] bytes_sent: Union[None, BaseException, int] packets_received: Union[None, BaseException, int] packets_sent: Union[None, BaseException, int] bytes_received_original: Union[None, BaseException, int] bytes_sent_original: Union[None, BaseException, int] packets_received_original: Union[None, BaseException, int] packets_sent_original: Union[None, BaseException, int] class IgdState(NamedTuple): """IGD state.""" timestamp: datetime bytes_received: Union[None, BaseException, int] bytes_sent: Union[None, BaseException, int] packets_received: Union[None, BaseException, int] packets_sent: Union[None, BaseException, int] connection_status: Union[None, BaseException, str] last_connection_error: Union[None, BaseException, str] uptime: Union[None, BaseException, int] external_ip_address: Union[None, BaseException, str] port_mapping_number_of_entries: Union[None, BaseException, int] # Derived values. kibibytes_per_sec_received: Union[None, float] kibibytes_per_sec_sent: Union[None, float] packets_per_sec_received: Union[None, float] packets_per_sec_sent: Union[None, float] class IgdStateItem(Enum): """ IGD state item. Used to specify what to request from the device. """ BYTES_RECEIVED = 1 BYTES_SENT = 2 PACKETS_RECEIVED = 3 PACKETS_SENT = 4 CONNECTION_STATUS = 5 LAST_CONNECTION_ERROR = 6 UPTIME = 7 EXTERNAL_IP_ADDRESS = 8 PORT_MAPPING_NUMBER_OF_ENTRIES = 9 KIBIBYTES_PER_SEC_RECEIVED = 11 KIBIBYTES_PER_SEC_SENT = 12 PACKETS_PER_SEC_RECEIVED = 13 PACKETS_PER_SEC_SENT = 14 def _derive_value_per_second( value_name: str, current_timestamp: datetime, current_value: Union[None, BaseException, StatusInfo, int, str], last_timestamp: Union[None, BaseException, datetime], last_value: Union[None, BaseException, StatusInfo, int, str], ) -> Union[None, float]: """Calculate average based on current and last value.""" if ( not isinstance(current_timestamp, datetime) or not isinstance(current_value, int) or not isinstance(last_timestamp, datetime) or not isinstance(last_value, int) ): return None if last_value > current_value: # Value has overflowed, don't try to calculate anything. return None delta_time = current_timestamp - last_timestamp delta_value: Union[int, float] = current_value - last_value if value_name in (BYTES_RECEIVED, BYTES_SENT): delta_value = delta_value / 1024 # 1KB return delta_value / delta_time.total_seconds() class IgdDevice(UpnpProfileDevice): """Representation of a IGD device.""" # pylint: disable=too-many-public-methods DEVICE_TYPES = [ "urn:schemas-upnp-org:device:InternetGatewayDevice:1", "urn:schemas-upnp-org:device:InternetGatewayDevice:2", ] _SERVICE_TYPES = { "WANIP6FC": { "urn:schemas-upnp-org:service:WANIPv6FirewallControl:1", }, "WANPPPC": { "urn:schemas-upnp-org:service:WANPPPConnection:1", }, "WANIPC": { "urn:schemas-upnp-org:service:WANIPConnection:1", "urn:schemas-upnp-org:service:WANIPConnection:2", }, "WANCIC": { "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", }, "L3FWD": { "urn:schemas-upnp-org:service:Layer3Forwarding:1", }, } def __init__( self, device: UpnpDevice, event_handler: Optional[UpnpEventHandler] ) -> None: """Initialize.""" super().__init__(device, event_handler) self._last_traffic_state = TrafficCounterState( timestamp=datetime.now(), bytes_received=None, bytes_sent=None, packets_received=None, packets_sent=None, bytes_received_original=None, bytes_sent_original=None, packets_received_original=None, packets_sent_original=None, ) self._offset_bytes_received = 0 self._offset_bytes_sent = 0 self._offset_packets_received = 0 self._offset_packets_sent = 0 def _any_action( self, service_names: Sequence[str], action_name: str ) -> Optional[UpnpAction]: for service_name in service_names: action = self._action(service_name, action_name) if action is not None: return action _LOGGER.debug("Could not find action %s/%s", service_names, action_name) return None def _any_state_variable( self, service_names: Sequence[str], variable_name: str ) -> Optional[UpnpStateVariable]: for service_name in service_names: state_var = self._state_variable(service_name, variable_name) if state_var is not None: return state_var _LOGGER.debug( "Could not find state variable %s/%s", service_names, variable_name ) return None @property def external_ip_address(self) -> Optional[str]: """ Get the external IP address, from the state variable ExternalIPAddress. This requires a subscription to the WANIPC/WANPPPC service. """ services = ["WANIPC", "WANPPPC"] state_var = self._any_state_variable(services, "ExternalIPAddress") if not state_var: return None external_ip_address: Optional[str] = state_var.value return external_ip_address @property def connection_status(self) -> Optional[str]: """ Get the connection status, from the state variable ConnectionStatus. This requires a subscription to the WANIPC/WANPPPC service. """ services = ["WANIPC", "WANPPPC"] state_var = self._any_state_variable(services, "ConnectionStatus") if not state_var: return None connection_status: Optional[str] = state_var.value return connection_status @property def port_mapping_number_of_entries(self) -> Optional[int]: """ Get number of port mapping entries, from the state variable `PortMappingNumberOfEntries`. This requires a subscription to the WANIPC/WANPPPC service. """ services = ["WANIPC", "WANPPPC"] state_var = self._any_state_variable(services, "PortMappingNumberOfEntries") if not state_var: return None number_of_entries: Optional[int] = state_var.value return number_of_entries async def async_get_total_bytes_received(self) -> Optional[int]: """Get total bytes received.""" action = self._action("WANCIC", "GetTotalBytesReceived") if not action: return None result = await action.async_call() total_bytes_received: Optional[int] = result.get("NewTotalBytesReceived") if total_bytes_received is None: return None if total_bytes_received < 0: self._offset_bytes_received = 2**31 return total_bytes_received + self._offset_bytes_received async def async_get_total_bytes_sent(self) -> Optional[int]: """Get total bytes sent.""" action = self._action("WANCIC", "GetTotalBytesSent") if not action: return None result = await action.async_call() total_bytes_sent: Optional[int] = result.get("NewTotalBytesSent") if total_bytes_sent is None: return None if total_bytes_sent < 0: self._offset_bytes_sent = 2**31 return total_bytes_sent + self._offset_bytes_sent async def async_get_total_packets_received(self) -> Optional[int]: """Get total packets received.""" action = self._action("WANCIC", "GetTotalPacketsReceived") if not action: return None result = await action.async_call() total_packets_received: Optional[int] = result.get("NewTotalPacketsReceived") if total_packets_received is None: return None if total_packets_received < 0: self._offset_packets_received = 2**31 return total_packets_received + self._offset_packets_received async def async_get_total_packets_sent(self) -> Optional[int]: """Get total packets sent.""" action = self._action("WANCIC", "GetTotalPacketsSent") if not action: return None result = await action.async_call() total_packets_sent: Optional[int] = result.get("NewTotalPacketsSent") if total_packets_sent is None: return None if total_packets_sent < 0: self._offset_packets_sent = 2**31 return total_packets_sent + self._offset_packets_sent async def async_get_enabled_for_internet(self) -> Optional[bool]: """Get internet access enabled state.""" action = self._action("WANCIC", "GetEnabledForInternet") if not action: return None result = await action.async_call() enabled_for_internet: Optional[bool] = result.get("NewEnabledForInternet") return enabled_for_internet async def async_set_enabled_for_internet(self, enabled: bool) -> None: """ Set internet access enabled state. :param enabled whether access should be enabled """ action = self._action("WANCIC", "SetEnabledForInternet") if not action: return await action.async_call(NewEnabledForInternet=enabled) async def async_get_common_link_properties(self) -> Optional[CommonLinkProperties]: """Get common link properties.""" action = self._action("WANCIC", "GetCommonLinkProperties") if not action: return None result = await action.async_call() return CommonLinkProperties( result["NewWANAccessType"], int(result["NewLayer1UpstreamMaxBitRate"]), int(result["NewLayer1DownstreamMaxBitRate"]), result["NewPhysicalLinkStatus"], ) async def async_get_external_ip_address( self, services: Optional[Sequence[str]] = None ) -> Optional[str]: """ Get the external IP address. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "GetExternalIPAddress") if not action: return None result = await action.async_call() external_ip_address: Optional[str] = result.get("NewExternalIPAddress") return external_ip_address async def async_get_generic_port_mapping_entry( self, port_mapping_index: int, services: Optional[List[str]] = None ) -> Optional[PortMappingEntry]: """ Get generic port mapping entry. :param port_mapping_index Index of port mapping entry :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "GetGenericPortMappingEntry") if not action: return None result = await action.async_call(NewPortMappingIndex=port_mapping_index) return PortMappingEntry( ( IPv4Address(result["NewRemoteHost"]) if result.get("NewRemoteHost") else None ), result["NewExternalPort"], result["NewProtocol"], result["NewInternalPort"], IPv4Address(result["NewInternalClient"]), result["NewEnabled"], result["NewPortMappingDescription"], ( timedelta(seconds=result["NewLeaseDuration"]) if result.get("NewLeaseDuration") else None ), ) async def async_get_specific_port_mapping_entry( self, remote_host: Optional[IPv4Address], external_port: int, protocol: str, services: Optional[List[str]] = None, ) -> Optional[PortMappingEntry]: """ Get specific port mapping entry. :param remote_host Address of remote host or None :param external_port External port :param protocol Protocol, 'TCP' or 'UDP' :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "GetSpecificPortMappingEntry") if not action: return None result = await action.async_call( NewRemoteHost=remote_host.exploded if remote_host else "", NewExternalPort=external_port, NewProtocol=protocol, ) return PortMappingEntry( remote_host, external_port, protocol, result["NewInternalPort"], IPv4Address(result["NewInternalClient"]), result["NewEnabled"], result["NewPortMappingDescription"], ( timedelta(seconds=result["NewLeaseDuration"]) if result.get("NewLeaseDuration") else None ), ) async def async_add_port_mapping( self, remote_host: IPv4Address, external_port: int, protocol: str, internal_port: int, internal_client: IPv4Address, enabled: bool, description: str, lease_duration: timedelta, services: Optional[List[str]] = None, ) -> None: """ Add a port mapping. :param remote_host Address of remote host or None :param external_port External port :param protocol Protocol, 'TCP' or 'UDP' :param internal_port Internal port :param internal_client Address of internal host :param enabled Port mapping enabled :param description Description for port mapping :param lease_duration Lease duration :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ # pylint: disable=too-many-arguments,too-many-positional-arguments services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "AddPortMapping") if not action: return await action.async_call( NewRemoteHost=remote_host.exploded, NewExternalPort=external_port, NewProtocol=protocol, NewInternalPort=internal_port, NewInternalClient=internal_client.exploded, NewEnabled=enabled, NewPortMappingDescription=description, NewLeaseDuration=int(lease_duration.seconds), ) async def async_delete_port_mapping( self, remote_host: IPv4Address, external_port: int, protocol: str, services: Optional[List[str]] = None, ) -> None: """ Delete an existing port mapping. :param remote_host Address of remote host or None :param external_port External port :param protocol Protocol, 'TCP' or 'UDP' :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "DeletePortMapping") if not action: return await action.async_call( NewRemoteHost=remote_host.exploded, NewExternalPort=external_port, NewProtocol=protocol, ) async def async_get_firewall_status(self) -> Optional[FirewallStatus]: """Get (IPv6) firewall status.""" action = self._action("WANIP6FC", "GetFirewallStatus") if not action: return None result = await action.async_call() return FirewallStatus( result["FirewallEnabled"], result["InboundPinholeAllowed"], ) async def async_add_pinhole( self, remote_host: IPv6Address, remote_port: int, internal_client: IPv6Address, internal_port: int, protocol: int, lease_time: timedelta, ) -> Optional[int]: """Add a pinhole.""" # pylint: disable=too-many-arguments,too-many-positional-arguments action = self._action("WANIP6FC", "AddPinhole") if not action: return None result = await action.async_call( RemoteHost=str(remote_host), RemotePort=remote_port, InternalClient=str(internal_client), InternalPort=internal_port, Protocol=protocol, LeaseTime=int(lease_time.total_seconds()), ) return cast(int, result["UniqueID"]) async def async_update_pinhole(self, pinhole_id: int, new_lease_time: int) -> None: """Update pinhole.""" action = self._action("WANIP6FC", "UpdatePinhole") if not action: return await action.async_call( UniqueID=pinhole_id, NewLeaseTime=new_lease_time, ) async def async_delete_pinhole(self, pinhole_id: int) -> None: """Delete an existing pinhole.""" action = self._action("WANIP6FC", "DeletePinhole") if not action: return await action.async_call( UniqueID=pinhole_id, ) async def async_get_pinhole_packets(self, pinhole_id: int) -> Optional[int]: """Get pinhole packet count.""" action = self._action("WANIP6FC", "GetPinholePackets") if not action: return None result = await action.async_call( UniqueID=pinhole_id, ) return cast(int, result["PinholePackets"]) async def async_get_connection_type_info( self, services: Optional[Sequence[str]] = None ) -> Optional[ConnectionTypeInfo]: """ Get connection type info. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "GetConnectionTypeInfo") if not action: return None result = await action.async_call() return ConnectionTypeInfo( result["NewConnectionType"], result["NewPossibleConnectionTypes"] ) async def async_set_connection_type( self, connection_type: str, services: Optional[List[str]] = None ) -> None: """ Set connection type. :param connection_type connection type :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "SetConnectionType") if not action: return await action.async_call(NewConnectionType=connection_type) async def async_request_connection( self, services: Optional[Sequence[str]] = None ) -> None: """ Request connection. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "RequestConnection") if not action: return await action.async_call() async def async_request_termination( self, services: Optional[Sequence[str]] = None ) -> None: """ Request connection termination. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "RequestTermination") if not action: return await action.async_call() async def async_force_termination( self, services: Optional[Sequence[str]] = None ) -> None: """ Force connection termination. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "ForceTermination") if not action: return await action.async_call() async def async_get_status_info( self, services: Optional[Sequence[str]] = None ) -> Optional[StatusInfo]: """ Get status info. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "GetStatusInfo") if not action: return None try: result = await action.async_call() except ValueError: _LOGGER.debug("Caught ValueError parsing results") return None return StatusInfo( result["NewConnectionStatus"], result["NewLastConnectionError"], result["NewUptime"], ) async def async_get_port_mapping_number_of_entries( self, services: Optional[Sequence[str]] = None ) -> Optional[int]: """ Get number of port mapping entries. Note that this action is not officially supported by the IGD specification. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "GetPortMappingNumberOfEntries") if not action: return None result = await action.async_call() number_of_entries: Optional[str] = result.get( "NewPortMappingNumberOfEntries" ) # str? if number_of_entries is None: return None return int(number_of_entries) async def async_get_nat_rsip_status( self, services: Optional[Sequence[str]] = None ) -> Optional[NatRsipStatusInfo]: """ Get NAT enabled and RSIP availability statuses. :param services List of service names to try to get action from, defaults to [WANIPC,WANPPPC] """ services = services or ["WANIPC", "WANPPPC"] action = self._any_action(services, "GetNATRSIPStatus") if not action: return None result = await action.async_call() return NatRsipStatusInfo(result["NewNATEnabled"], result["NewRSIPAvailable"]) async def async_get_default_connection_service(self) -> Optional[str]: """Get default connection service.""" action = self._action("L3FWD", "GetDefaultConnectionService") if not action: return None result = await action.async_call() default_connection_service: Optional[str] = result.get( "NewDefaultConnectionService" ) return default_connection_service async def async_set_default_connection_service(self, service: str) -> None: """ Set default connection service. :param service default connection service """ action = self._action("L3FWD", "SetDefaultConnectionService") if not action: return await action.async_call(NewDefaultConnectionService=service) async def async_get_traffic_and_status_data( self, items: Optional[Set[IgdStateItem]] = None, force_poll: bool = False, ) -> IgdState: """ Get all traffic data at once, including derived data. Data: * total bytes received * total bytes sent * total packets received * total packets sent * bytes per second received (derived from last update) * bytes per second sent (derived from last update) * packets per second received (derived from last update) * packets per second sent (derived from last update) * connection status (status info) * last connection error (status info) * uptime (status info) * external IP address * number of port mapping entries """ # pylint: disable=too-many-locals items = items or set(IgdStateItem) async def nop() -> None: """Pass.""" external_ip_address: Optional[str] = None connection_status: Optional[str] = None port_mapping_number_of_entries: Optional[int] = None if not force_poll: if ( IgdStateItem.EXTERNAL_IP_ADDRESS in items and (external_ip_address := self.external_ip_address) is not None ): items.remove(IgdStateItem.EXTERNAL_IP_ADDRESS) if ( IgdStateItem.CONNECTION_STATUS in items and (connection_status := self.connection_status) is not None ): items.remove(IgdStateItem.CONNECTION_STATUS) if ( IgdStateItem.PORT_MAPPING_NUMBER_OF_ENTRIES in items and ( port_mapping_number_of_entries := self.port_mapping_number_of_entries ) is not None ): items.remove(IgdStateItem.PORT_MAPPING_NUMBER_OF_ENTRIES) timestamp = datetime.now() values = await asyncio.gather( ( self.async_get_total_bytes_received() if IgdStateItem.BYTES_RECEIVED in items or IgdStateItem.KIBIBYTES_PER_SEC_RECEIVED in items else nop() ), ( self.async_get_total_bytes_sent() if IgdStateItem.BYTES_SENT in items or IgdStateItem.KIBIBYTES_PER_SEC_SENT in items else nop() ), ( self.async_get_total_packets_received() if IgdStateItem.PACKETS_RECEIVED in items or IgdStateItem.PACKETS_PER_SEC_RECEIVED in items else nop() ), ( self.async_get_total_packets_sent() if IgdStateItem.PACKETS_SENT in items or IgdStateItem.PACKETS_PER_SEC_SENT in items else nop() ), ( self.async_get_status_info() if IgdStateItem.CONNECTION_STATUS in items or IgdStateItem.LAST_CONNECTION_ERROR in items or IgdStateItem.UPTIME in items else nop() ), ( self.async_get_external_ip_address() if IgdStateItem.EXTERNAL_IP_ADDRESS in items else nop() ), ( self.async_get_port_mapping_number_of_entries() if IgdStateItem.PORT_MAPPING_NUMBER_OF_ENTRIES in items else nop() ), return_exceptions=True, ) kibibytes_per_sec_received = _derive_value_per_second( BYTES_RECEIVED, timestamp, values[0], self._last_traffic_state.timestamp, self._last_traffic_state.bytes_received, ) kibibytes_per_sec_sent = _derive_value_per_second( BYTES_SENT, timestamp, values[1], self._last_traffic_state.timestamp, self._last_traffic_state.bytes_sent, ) packets_per_sec_received = _derive_value_per_second( PACKETS_RECEIVED, timestamp, values[2], self._last_traffic_state.timestamp, self._last_traffic_state.packets_received, ) packets_per_sec_sent = _derive_value_per_second( PACKETS_SENT, timestamp, values[3], self._last_traffic_state.timestamp, self._last_traffic_state.packets_sent, ) self._last_traffic_state = TrafficCounterState( timestamp=timestamp, bytes_received=cast(Union[int, BaseException, None], values[0]), bytes_sent=cast(Union[int, BaseException, None], values[1]), packets_received=cast(Union[int, BaseException, None], values[2]), packets_sent=cast(Union[int, BaseException, None], values[3]), bytes_received_original=cast(Union[int, BaseException, None], values[0]), bytes_sent_original=cast(Union[int, BaseException, None], values[1]), packets_received_original=cast(Union[int, BaseException, None], values[2]), packets_sent_original=cast(Union[int, BaseException, None], values[3]), ) # Test if any of the calls were ok. If not, raise the exception. non_exceptions = [ value for value in values if not isinstance(value, BaseException) ] if not non_exceptions: # Raise any exception to indicate something was very wrong. exc = cast(BaseException, values[0]) raise exc return IgdState( timestamp=timestamp, bytes_received=cast(Union[None, BaseException, int], values[0]), bytes_sent=cast(Union[None, BaseException, int], values[1]), packets_received=cast(Union[None, BaseException, int], values[2]), packets_sent=cast(Union[None, BaseException, int], values[3]), kibibytes_per_sec_received=kibibytes_per_sec_received, kibibytes_per_sec_sent=kibibytes_per_sec_sent, packets_per_sec_received=packets_per_sec_received, packets_per_sec_sent=packets_per_sec_sent, connection_status=( values[4].connection_status if isinstance(values[4], StatusInfo) else connection_status ), last_connection_error=( values[4].last_connection_error if isinstance(values[4], StatusInfo) else None ), uptime=values[4].uptime if isinstance(values[4], StatusInfo) else None, external_ip_address=cast( Union[None, BaseException, str], values[5] or external_ip_address ), port_mapping_number_of_entries=cast( Union[None, int], values[6] or port_mapping_number_of_entries ), ) async_upnp_client-0.44.0/async_upnp_client/profiles/printer.py000066400000000000000000000023061477256211100247200ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.profiles.printer module.""" import logging from typing import List, NamedTuple, Optional from async_upnp_client.profiles.profile import UpnpProfileDevice _LOGGER = logging.getLogger(__name__) PrinterAttributes = NamedTuple( "PrinterAttributes", [ ("printer_state", str), ("printer_state_reasons", str), ("job_id_list", List[int]), ("job_id", int), ], ) class PrinterDevice(UpnpProfileDevice): """Representation of a printer device.""" DEVICE_TYPES = [ "urn:schemas-upnp-org:device:printer:1", ] _SERVICE_TYPES = { "BASIC": { "urn:schemas-upnp-org:service:PrintBasic:1", }, } async def async_get_printer_attributes(self) -> Optional[PrinterAttributes]: """Get printer attributes.""" action = self._action("BASIC", "GetPrinterAttributes") if not action: return None result = await action.async_call() return PrinterAttributes( result["PrinterState"], result["PrinterStateReasons"], [int(x) for x in result["JobIdList"].split(",")], int(result["JobId"]), ) async_upnp_client-0.44.0/async_upnp_client/profiles/profile.py000066400000000000000000000417601477256211100247040ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.profiles.profile module.""" import asyncio import logging import time from datetime import timedelta from typing import Any, Dict, FrozenSet, List, Optional, Sequence, Set, Union from async_upnp_client.client import ( EventCallbackType, UpnpAction, UpnpDevice, UpnpService, UpnpStateVariable, UpnpValueError, ) from async_upnp_client.const import AddressTupleVXType from async_upnp_client.event_handler import UpnpEventHandler from async_upnp_client.exceptions import ( UpnpConnectionError, UpnpError, UpnpResponseError, ) from async_upnp_client.search import async_search from async_upnp_client.ssdp import SSDP_MX from async_upnp_client.utils import CaseInsensitiveDict _LOGGER = logging.getLogger(__name__) SUBSCRIBE_TIMEOUT = timedelta(minutes=9) RESUBSCRIBE_TOLERANCE = timedelta(minutes=1) RESUBSCRIBE_TOLERANCE_SECS = RESUBSCRIBE_TOLERANCE.total_seconds() def find_device_of_type(device: UpnpDevice, device_types: List[str]) -> UpnpDevice: """Find the (embedded) UpnpDevice of any of the device types.""" for device_ in device.all_devices: if device_.device_type in device_types: return device_ raise UpnpError(f"Could not find device of type: {device_types}") class UpnpProfileDevice: """ Base class for UpnpProfileDevices. Override _SERVICE_TYPES for aliases. Override SERVICE_IDS for required service_id values. """ DEVICE_TYPES: List[str] = [] SERVICE_IDS: FrozenSet[str] = frozenset() _SERVICE_TYPES: Dict[str, Set[str]] = {} @classmethod async def async_search( cls, source: Optional[AddressTupleVXType] = None, timeout: int = SSDP_MX ) -> Set[CaseInsensitiveDict]: """ Search for this device type. This only returns search info, not a profile itself. :param source_ip Source IP to scan from :param timeout Timeout to use :return: Set of devices (dicts) found """ responses = set() async def on_response(data: CaseInsensitiveDict) -> None: if "st" in data and data["st"] in cls.DEVICE_TYPES: responses.add(data) await async_search(async_callback=on_response, source=source, timeout=timeout) return responses @classmethod async def async_discover(cls) -> Set[CaseInsensitiveDict]: """Alias for async_search.""" return await cls.async_search() @classmethod def is_profile_device(cls, device: UpnpDevice) -> bool: """Check for device's support of the profile defined in this (sub)class. The device must be (or have an embedded device) that matches the class device type, and it must provide all services that are defined by this class. """ try: profile_device = find_device_of_type(device, cls.DEVICE_TYPES) except UpnpError: return False # Check that every service required by the subclass is declared by the device device_service_ids = { service.service_id for service in profile_device.all_services } if not cls.SERVICE_IDS.issubset(device_service_ids): return False return True def __init__( self, device: UpnpDevice, event_handler: Optional[UpnpEventHandler] ) -> None: """Initialize.""" self.device = device self.profile_device = find_device_of_type(device, self.DEVICE_TYPES) self._event_handler = event_handler self.on_event: Optional[EventCallbackType] = None self._icon: Optional[str] = None # Map of SID to renewal timestamp (monotonic clock seconds) self._subscriptions: Dict[str, float] = {} self._resubscriber_task: Optional[asyncio.Task] = None @property def name(self) -> str: """Get the name of the device.""" return self.profile_device.name @property def manufacturer(self) -> str: """Get the manufacturer of this device.""" return self.profile_device.manufacturer @property def model_description(self) -> Optional[str]: """Get the model description of this device.""" return self.profile_device.model_description @property def model_name(self) -> str: """Get the model name of this device.""" return self.profile_device.model_name @property def model_number(self) -> Optional[str]: """Get the model number of this device.""" return self.profile_device.model_number @property def serial_number(self) -> Optional[str]: """Get the serial number of this device.""" return self.profile_device.serial_number @property def udn(self) -> str: """Get the UDN of the device.""" return self.profile_device.udn @property def device_type(self) -> str: """Get the device type of this device.""" return self.profile_device.device_type @property def icon(self) -> Optional[str]: """Get a URL for the biggest icon for this device.""" if not self.profile_device.icons: return None if not self._icon: icon_mime_preference = {"image/png": 3, "image/jpeg": 2, "image/gif": 1} icons = [icon for icon in self.profile_device.icons if icon.url] icons = sorted( icons, # Sort by area, then colour depth, then preferred mimetype key=lambda icon: ( icon.width * icon.height, icon.depth, icon_mime_preference.get(icon.mimetype, 0), ), reverse=True, ) self._icon = icons[0].url return self._icon def _service(self, service_type_abbreviation: str) -> Optional[UpnpService]: """Get UpnpService by service_type or alias.""" if not self.profile_device: return None if service_type_abbreviation not in self._SERVICE_TYPES: return None for service_type in self._SERVICE_TYPES[service_type_abbreviation]: service = self.profile_device.find_service(service_type) if service: return service return None def _state_variable( self, service_name: str, state_variable_name: str ) -> Optional[UpnpStateVariable]: """Get state_variable from service.""" service = self._service(service_name) if not service: return None if not service.has_state_variable(state_variable_name): return None return service.state_variable(state_variable_name) def _action(self, service_name: str, action_name: str) -> Optional[UpnpAction]: """Check if service has action.""" service = self._service(service_name) if not service: return None if not service.has_action(action_name): return None return service.action(action_name) def _interesting_service(self, service: UpnpService) -> bool: """Check if service is a service we're interested in.""" service_type = service.service_type for service_types in self._SERVICE_TYPES.values(): if service_type in service_types: return True return False async def _async_resubscribe_services( self, now: Optional[float] = None, notify_errors: bool = False ) -> None: """Renew existing subscriptions. :param now: time.monotonic reference for current time :param notify_errors: Call on_event in case of error instead of raising """ assert self._event_handler if now is None: now = time.monotonic() renewal_threshold = now - RESUBSCRIBE_TOLERANCE_SECS _LOGGER.debug("Resubscribing to services with threshold %f", renewal_threshold) for sid, renewal_time in list(self._subscriptions.items()): if renewal_time < renewal_threshold: _LOGGER.debug("Skipping %s with renewal_time %f", sid, renewal_time) continue _LOGGER.debug("Resubscribing to %s with renewal_time %f", sid, renewal_time) # Subscription is going to be changed, no matter what del self._subscriptions[sid] # Determine service for on_event call in case of failure service = self._event_handler.service_for_sid(sid) if not service: _LOGGER.error("Subscription for %s was lost", sid) continue try: new_sid, timeout = await self._event_handler.async_resubscribe( sid, timeout=SUBSCRIBE_TIMEOUT ) except UpnpError as err: if isinstance(err, UpnpConnectionError): # Device has gone offline self.profile_device.available = False _LOGGER.warning("Failed (re-)subscribing to: %s, reason: %r", sid, err) if notify_errors: # Notify event listeners that something has changed self._on_event(service, []) else: raise else: self._subscriptions[new_sid] = now + timeout.total_seconds() async def _resubscribe_loop(self) -> None: """Periodically resubscribes to current subscriptions.""" _LOGGER.debug("_resubscribe_loop started") while self._subscriptions: next_renewal = min(self._subscriptions.values()) wait_time = next_renewal - time.monotonic() - RESUBSCRIBE_TOLERANCE_SECS _LOGGER.debug("Resubscribing in %f seconds", wait_time) if wait_time > 0: await asyncio.sleep(wait_time) await self._async_resubscribe_services(notify_errors=True) _LOGGER.debug("_resubscribe_loop ended because of no subscriptions") async def _update_resubscriber_task(self) -> None: """Start or stop the resubscriber task, depending on having subscriptions.""" # Clear out done task to make later logic easier if self._resubscriber_task and self._resubscriber_task.cancelled(): self._resubscriber_task = None if self._subscriptions and not self._resubscriber_task: _LOGGER.debug("Creating resubscribe_task") # pylint: disable=fixme self._resubscriber_task = asyncio.create_task( self._resubscribe_loop(), name=f"UpnpProfileDevice({self.name})._resubscriber_task", ) if not self._subscriptions and self._resubscriber_task: _LOGGER.debug("Cancelling resubscribe_task") self._resubscriber_task.cancel() try: await self._resubscriber_task except asyncio.CancelledError: pass self._resubscriber_task = None async def async_subscribe_services( self, auto_resubscribe: bool = False ) -> Optional[timedelta]: """(Re-)Subscribe to services. :param auto_resubscribe: Automatically resubscribe to subscriptions before they expire. If this is enabled, failure to resubscribe will be indicated by on_event being called with the failed service and an empty state_variables list. :return: time until this next needs to be called, or None if manual resubscription is not needed. :raise UpnpResponseError: Device rejected subscription request. State variables will need to be polled. :raise UpnpError or subclass: Failed to subscribe to all interesting services. """ if not self._event_handler: _LOGGER.info("No event_handler, event handling disabled") return None # Using time.monotonic to avoid problems with system clock changes now = time.monotonic() try: if self._subscriptions: # Resubscribe existing subscriptions await self._async_resubscribe_services(now) else: # Subscribe to services we are interested in for service in self.profile_device.all_services: if not self._interesting_service(service): continue _LOGGER.debug("Subscribing to service: %s", service) service.on_event = self._on_event new_sid, timeout = await self._event_handler.async_subscribe( service, timeout=SUBSCRIBE_TIMEOUT ) self._subscriptions[new_sid] = now + timeout.total_seconds() except UpnpError as err: if isinstance(err, UpnpResponseError) and not self._subscriptions: _LOGGER.info("Device rejected subscription request: %r", err) else: _LOGGER.warning("Failed subscribing to service: %r", err) # Unsubscribe anything that was subscribed, no half-done subscriptions try: await self.async_unsubscribe_services() except UpnpError: pass raise if not self._subscriptions: return None if auto_resubscribe: await self._update_resubscriber_task() return None lowest_timeout_delta = min(self._subscriptions.values()) - now resubcription_timeout = ( timedelta(seconds=lowest_timeout_delta) - RESUBSCRIBE_TOLERANCE ) return max(resubcription_timeout, timedelta(seconds=0)) async def _async_unsubscribe_service(self, sid: str) -> None: """Unsubscribe from one service, handling possible exceptions.""" assert self._event_handler try: await self._event_handler.async_unsubscribe(sid) except UpnpError as err: _LOGGER.debug("Failed unsubscribing from: %s, reason: %r", sid, err) except KeyError: _LOGGER.warning( "%s was already unsubscribed. AiohttpNotifyServer was " "probably stopped before we could unsubscribe.", sid, ) async def async_unsubscribe_services(self) -> None: """Unsubscribe from all of our subscribed services.""" # Delete list of subscriptions and cancel renewal before unsubscribing # to avoid unsub-resub race. sids = list(self._subscriptions) self._subscriptions.clear() await self._update_resubscriber_task() await asyncio.gather(*(self._async_unsubscribe_service(sid) for sid in sids)) @property def is_subscribed(self) -> bool: """Get current service subscription state.""" return bool(self._subscriptions) async def _async_poll_state_variables( self, service_name: str, action_names: Union[str, Sequence[str]], **in_args: Any ) -> None: """Update state variables by polling actions that return their values. Assumes that the actions's relatedStateVariable names the correct state variable for updating. """ service = self._service(service_name) if not service: _LOGGER.debug("Can't poll missing service %s", service_name) return if isinstance(action_names, str): action_names = [action_names] changed_state_variables: List[UpnpStateVariable] = [] for action_name in action_names: try: action = service.action(action_name) except KeyError: _LOGGER.debug( "Can't poll missing action %s:%s for state variables", service_name, action_name, ) continue try: result = await action.async_call(**in_args) except UpnpResponseError as err: _LOGGER.debug( "Failed to call action %s:%s for state variables: %r", service_name, action_name, err, ) continue for arg in action.arguments: if arg.direction != "out": continue if arg.name not in result: continue if arg.related_state_variable.value == arg.value: continue try: arg.related_state_variable.value = arg.value except UpnpValueError: continue changed_state_variables.append(arg.related_state_variable) if changed_state_variables: self._on_event(service, changed_state_variables) def _on_event( self, service: UpnpService, state_variables: Sequence[UpnpStateVariable] ) -> None: """ State variable(s) changed. Override to handle events. :param service Service which sent the event. :param state_variables State variables which have been changed. """ if self.on_event: self.on_event(service, state_variables) # pylint: disable=not-callable async_upnp_client-0.44.0/async_upnp_client/py.typed000066400000000000000000000000001477256211100225240ustar00rootroot00000000000000async_upnp_client-0.44.0/async_upnp_client/search.py000066400000000000000000000151361477256211100226640ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.search module.""" import asyncio import logging import socket import sys from asyncio import DatagramTransport from asyncio.events import AbstractEventLoop from ipaddress import IPv4Address, IPv6Address from typing import Any, Callable, Coroutine, Optional, cast from async_upnp_client.const import SsdpSource from async_upnp_client.ssdp import ( SSDP_DISCOVER, SSDP_MX, SSDP_ST_ALL, AddressTupleVXType, IPvXAddress, SsdpProtocol, build_ssdp_search_packet, determine_source_target, get_host_string, get_ssdp_socket, ) from async_upnp_client.utils import CaseInsensitiveDict _LOGGER = logging.getLogger(__name__) class SsdpSearchListener: """SSDP Search (response) listener.""" # pylint: disable=too-many-instance-attributes def __init__( self, async_callback: Optional[ Callable[[CaseInsensitiveDict], Coroutine[Any, Any, None]] ] = None, callback: Optional[Callable[[CaseInsensitiveDict], None]] = None, loop: Optional[AbstractEventLoop] = None, source: Optional[AddressTupleVXType] = None, target: Optional[AddressTupleVXType] = None, timeout: int = SSDP_MX, search_target: str = SSDP_ST_ALL, async_connect_callback: Optional[ Callable[[], Coroutine[Any, Any, None]] ] = None, connect_callback: Optional[Callable[[], None]] = None, ) -> None: """Init the ssdp listener class.""" # pylint: disable=too-many-arguments,too-many-positional-arguments assert ( callback is not None or async_callback is not None ), "Provide at least one callback" self.async_callback = async_callback self.callback = callback self.async_connect_callback = async_connect_callback self.connect_callback = connect_callback self.search_target = search_target self.source, self.target = determine_source_target(source, target) self.timeout = timeout self.loop = loop or asyncio.get_event_loop() self._target_host: Optional[str] = None self._transport: Optional[DatagramTransport] = None def async_search( self, override_target: Optional[AddressTupleVXType] = None ) -> None: """Start an SSDP search.""" assert self._transport is not None sock: Optional[socket.socket] = self._transport.get_extra_info("socket") _LOGGER.debug( "Sending SEARCH packet, transport: %s, socket: %s, override_target: %s", self._transport, sock, override_target, ) assert self._target_host is not None, "Call async_start() first" packet = build_ssdp_search_packet(self.target, self.timeout, self.search_target) protocol = cast(SsdpProtocol, self._transport.get_protocol()) target = override_target or self.target protocol.send_ssdp_packet(packet, target) def _on_data(self, request_line: str, headers: CaseInsensitiveDict) -> None: """Handle data.""" if headers.get_lower("man") == SSDP_DISCOVER: # Ignore discover packets. return if headers.get_lower("nts"): _LOGGER.debug( "Got non-search response packet: %s, %s", request_line, headers ) return if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Received search response, _remote_addr: %s, USN: %s, location: %s", headers.get_lower("_remote_addr", ""), headers.get_lower("usn", ""), headers.get_lower("location", ""), ) headers["_source"] = SsdpSource.SEARCH if self._target_host and self._target_host != headers["_host"]: return if self.async_callback: coro = self.async_callback(headers) self.loop.create_task(coro) if self.callback: self.callback(headers) def _on_connect(self, transport: DatagramTransport) -> None: sock: Optional[socket.socket] = transport.get_extra_info("socket") _LOGGER.debug("On connect, transport: %s, socket: %s", transport, sock) self._transport = transport if self.async_connect_callback: coro = self.async_connect_callback() self.loop.create_task(coro) if self.connect_callback: self.connect_callback() @property def target_ip(self) -> IPvXAddress: """Get target IP.""" if len(self.target) == 4: return IPv6Address(self.target[0]) return IPv4Address(self.target[0]) async def async_start(self) -> None: """Start the listener.""" _LOGGER.debug("Start listening for search responses") sock, _source, _target = get_ssdp_socket(self.source, self.target) if sys.platform.startswith("win32"): address = self.source _LOGGER.debug("Binding socket, socket: %s, address: %s", sock, address) sock.bind(address) if not self.target_ip.is_multicast: self._target_host = get_host_string(self.target) else: self._target_host = "" loop = self.loop await loop.create_datagram_endpoint( lambda: SsdpProtocol( loop, on_connect=self._on_connect, on_data=self._on_data, ), sock=sock, ) def async_stop(self) -> None: """Stop the listener.""" if self._transport: self._transport.close() async def async_search( async_callback: Callable[[CaseInsensitiveDict], Coroutine[Any, Any, None]], timeout: int = SSDP_MX, search_target: str = SSDP_ST_ALL, source: Optional[AddressTupleVXType] = None, target: Optional[AddressTupleVXType] = None, loop: Optional[AbstractEventLoop] = None, ) -> None: """Discover devices via SSDP.""" # pylint: disable=too-many-arguments,too-many-positional-arguments loop_: AbstractEventLoop = loop or asyncio.get_event_loop() listener: Optional[SsdpSearchListener] = None async def _async_connected() -> None: nonlocal listener assert listener is not None listener.async_search() listener = SsdpSearchListener( async_callback=async_callback, loop=loop_, source=source, target=target, timeout=timeout, search_target=search_target, async_connect_callback=_async_connected, ) await listener.async_start() # Wait for devices to respond. await asyncio.sleep(timeout) listener.async_stop() async_upnp_client-0.44.0/async_upnp_client/server.py000066400000000000000000001443501477256211100227260ustar00rootroot00000000000000# -*- coding: utf-8 -*- """UPnP Server.""" # pylint: disable=too-many-lines import asyncio import logging import socket import sys import time import xml.etree.ElementTree as ET from asyncio.transports import DatagramTransport from datetime import datetime, timedelta, timezone from functools import partial, wraps from itertools import cycle from random import randrange from time import mktime from typing import ( Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Type, Union, cast, ) from urllib.parse import urlparse from uuid import uuid4 from wsgiref.handlers import format_date_time import defusedxml.ElementTree as DET # pylint: disable=import-error import voluptuous as vol from aiohttp.web import ( Application, AppRunner, HTTPBadRequest, Request, Response, RouteDef, TCPSite, ) from async_upnp_client import __version__ as version from async_upnp_client.aiohttp import AiohttpRequester from async_upnp_client.client import ( T, UpnpAction, UpnpDevice, UpnpError, UpnpRequester, UpnpService, UpnpStateVariable, ) from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import ( STATE_VARIABLE_TYPE_MAPPING, ActionArgumentInfo, ActionInfo, AddressTupleVXType, DeviceInfo, EventableStateVariableTypeInfo, HttpRequest, NotificationSubType, ServiceInfo, StateVariableInfo, StateVariableTypeInfo, ) from async_upnp_client.exceptions import ( UpnpActionError, UpnpActionErrorCode, UpnpValueError, ) from async_upnp_client.ssdp import ( _LOGGER_TRAFFIC_SSDP, SSDP_DISCOVER, SSDP_ST_ALL, SSDP_ST_ROOTDEVICE, SsdpProtocol, build_ssdp_packet, determine_source_target, get_ssdp_socket, is_ipv6_address, ) from async_upnp_client.utils import CaseInsensitiveDict NAMESPACES = { "s": "http://schemas.xmlsoap.org/soap/envelope/", "es": "http://schemas.xmlsoap.org/soap/encoding/", } HEADER_SERVER = f"async-upnp-client/{version} UPnP/2.0 Server/1.0" HEADER_CACHE_CONTROL = "max-age=1800" SSDP_SEARCH_RESPONDER_OPTIONS = "ssdp_search_responder_options" SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE = ( "ssdp_search_responder_always_rootdevice" ) SSDP_SEARCH_RESPONDER_OPTION_HEADERS = "search_headers" SSDP_ADVERTISEMENT_ANNOUNCER_OPTIONS = "ssdp_advertisement_announcer_options" SSDP_ADVERTISEMENT_ANNOUNCER_OPTION_HEADERS = "advertisement_headers" _LOGGER = logging.getLogger(__name__) _LOGGER_TRAFFIC_UPNP = logging.getLogger("async_upnp_client.traffic.upnp") # Hack: Bend INFO to DEBUG. _LOGGER_TRAFFIC_UPNP.info = _LOGGER_TRAFFIC_UPNP.debug # type: ignore class NopRequester(UpnpRequester): # pylint: disable=too-few-public-methods """NopRequester, does nothing.""" class EventSubscriber: """Represent a service subscriber.""" DEFAULT_TIMEOUT = 3600 def __init__(self, callback_url: str, timeout: Optional[int]) -> None: """Initialize.""" self._url = callback_url self._uuid = str(uuid4()) self._event_key = 0 self._expires = datetime.now() self.timeout = timeout @property def url(self) -> str: """Return callback URL.""" return self._url @property def uuid(self) -> str: """Return subscriber uuid.""" return self._uuid @property def timeout(self) -> Optional[int]: """Return timeout in seconds.""" return self._timeout @timeout.setter def timeout(self, timeout: Optional[int]) -> None: """Set timeout before unsubscribe.""" if timeout is None: timeout = self.DEFAULT_TIMEOUT self._timeout = timeout self._expires = datetime.now() + timedelta(seconds=timeout) @property def expiration(self) -> datetime: """Return expiration time of subscription.""" return self._expires def get_next_seq(self) -> int: """Return the next sequence number for an event.""" res = self._event_key self._event_key += 1 if self._event_key > 0xFFFF_FFFF: self._event_key = 1 return res class UpnpEventableStateVariable(UpnpStateVariable): """Representation of an eventable State Variable.""" def __init__( self, state_variable_info: StateVariableInfo, schema: vol.Schema ) -> None: """Initialize.""" super().__init__(state_variable_info, schema) self._last_sent = datetime.fromtimestamp(0, timezone.utc) self._defered_event: Optional[asyncio.TimerHandle] = None self._sent_event = asyncio.Event() @property def event_triggered(self) -> asyncio.Event: """Return event object for trigger completion.""" return self._sent_event @property def max_rate(self) -> float: """Return max event rate.""" type_info = cast( EventableStateVariableTypeInfo, self._state_variable_info.type_info ) return type_info.max_rate or 0.0 @property def value(self) -> Optional[T]: """Get Python value for this argument.""" return super().value @value.setter def value(self, value: Any) -> None: """Set value, python typed.""" if self._value == value: return super(UpnpEventableStateVariable, self.__class__).value.__set__(self, value) # type: ignore if not self.service or self._defered_event: return assert self._updated_at next_update = self._last_sent + timedelta(seconds=self.max_rate) if self._updated_at >= next_update: asyncio.create_task(self.trigger_event()) else: loop = asyncio.get_running_loop() self._defered_event = loop.call_at( next_update.timestamp(), self.trigger_event ) async def trigger_event(self) -> None: """Update any waiting subscribers.""" self._last_sent = datetime.now(timezone.utc) service = self.service assert isinstance(service, UpnpServerService) self._sent_event.set() asyncio.create_task(service.async_send_events()) # pylint: disable=no-member class UpnpServerAction(UpnpAction): """Representation of an Action.""" async def async_handle(self, **kwargs: Any) -> Any: """Handle action.""" self.validate_arguments(**kwargs) raise NotImplementedError() class UpnpServerService(UpnpService): """UPnP Service representation.""" SERVICE_DEFINITION: ServiceInfo STATE_VARIABLE_DEFINITIONS: Mapping[str, StateVariableTypeInfo] def __init__(self, requester: UpnpRequester) -> None: """Initialize.""" super().__init__(requester, self.SERVICE_DEFINITION, [], []) self._init_state_variables() self._init_actions() self._subscribers: List[EventSubscriber] = [] def _init_state_variables(self) -> None: """Initialize state variables from STATE_VARIABLE_DEFINITIONS.""" for name, type_info in self.STATE_VARIABLE_DEFINITIONS.items(): self.create_state_var(name, type_info) def create_state_var( self, name: str, type_info: StateVariableTypeInfo ) -> UpnpStateVariable: """Create UpnpStateVariable.""" existing = self.state_variables.get(name, None) if existing is not None: raise UpnpError(f"StateVariable with the same name exists: {name}") state_var_info = StateVariableInfo( name, send_events=isinstance(type_info, EventableStateVariableTypeInfo), type_info=type_info, xml=ET.Element("stateVariable"), ) # pylint: disable=protected-access state_var: UpnpStateVariable if isinstance(type_info, EventableStateVariableTypeInfo): state_var = UpnpEventableStateVariable( state_var_info, UpnpFactory(self.requester)._state_variable_create_schema(type_info), ) else: state_var = UpnpStateVariable( state_var_info, UpnpFactory(self.requester)._state_variable_create_schema(type_info), ) state_var.service = self if type_info.default_value is not None: state_var.upnp_value = type_info.default_value self.state_variables[state_var.name] = state_var return state_var def _init_actions(self) -> None: """Initialize actions from annotated methods.""" for item in dir(self): if item in ("control_url", "event_sub_url", "scpd_url", "device"): continue thing = getattr(self, item, None) if not thing or not hasattr(thing, "__upnp_action__"): continue self._init_action(thing) def _init_action(self, func: Callable) -> UpnpAction: """Initialize action for method.""" name, in_args, out_args = cast( Tuple[str, Mapping[str, str], Mapping[str, str]], getattr(func, "__upnp_action__"), ) arg_infos: List[ActionArgumentInfo] = [] args: List[UpnpAction.Argument] = [] for arg_name, state_var_name in in_args.items(): # Validate function has parameter. assert arg_name in func.__annotations__ # Validate parameter type. annotation = func.__annotations__.get(arg_name, None) state_var = self.state_variable(state_var_name) assert state_var.data_type_mapping["type"] == annotation # Build in-argument. arg_info = ActionArgumentInfo( arg_name, direction="in", state_variable_name=state_var.name, xml=ET.Element("server_argument"), ) arg_infos.append(arg_info) arg = UpnpAction.Argument(arg_info, state_var) args.append(arg) for arg_name, state_var_name in out_args.items(): # Build out-argument. state_var = self.state_variable(state_var_name) arg_info = ActionArgumentInfo( arg_name, direction="out", state_variable_name=state_var.name, xml=ET.Element("server_argument"), ) arg_infos.append(arg_info) arg = UpnpAction.Argument(arg_info, state_var) args.append(arg) action_info = ActionInfo( name=name, arguments=arg_infos, xml=ET.Element("server_action"), ) action = UpnpServerAction(action_info, args) action.async_handle = func # type: ignore action.service = self self.actions[name] = action return action async def async_handle_action(self, action_name: str, **kwargs: Any) -> Any: """Handle action.""" action = cast(UpnpServerAction, self.actions[action_name]) action.validate_arguments(**kwargs) return await action.async_handle(**kwargs) def add_subscriber(self, subscriber: EventSubscriber) -> None: """Add or update a subscriber.""" self._subscribers.append(subscriber) def del_subscriber(self, sid: str) -> bool: """Delete a subscriber.""" subscriber = self.get_subscriber(sid) if subscriber: self._subscribers.remove(subscriber) return True return False def get_subscriber(self, sid: str) -> Optional[EventSubscriber]: """Get matching subscriber (if any).""" for subscriber in self._subscribers: if subscriber.uuid == sid: return subscriber return None async def async_send_events( self, subscriber: Optional[EventSubscriber] = None ) -> None: """Send event updates to any subscribers.""" if not subscriber: now = datetime.now() self._subscribers = [ _sub for _sub in self._subscribers if now < _sub.expiration ] subscribers = self._subscribers if not self._subscribers: return else: subscribers = [subscriber] event_el = ET.Element("e:propertyset") event_el.set("xmlns:e", "urn:schemas-upnp-org:event-1-0") for state_var in self.state_variables.values(): if not isinstance(state_var, UpnpEventableStateVariable): continue prop_el = ET.SubElement(event_el, "e:property") ET.SubElement(prop_el, state_var.name).text = str(state_var.value) message = ET.tostring(event_el, encoding="utf-8", xml_declaration=True).decode() headers = { "CONTENT-TYPE": 'text/xml; charset="utf-8"', "NT": "upnp:event", "NTS": "upnp:propchange", } tasks = [] for sub in subscribers: hdr = headers.copy() hdr["SID"] = sub.uuid hdr["SEQ"] = str(sub.get_next_seq()) tasks.append( self.requester.async_http_request( HttpRequest("NOTIFY", sub.url, headers=hdr, body=message) ) ) await asyncio.gather(*tasks) class UpnpServerDevice(UpnpDevice): """UPnP Device representation.""" DEVICE_DEFINITION: DeviceInfo EMBEDDED_DEVICES: Sequence[Type["UpnpServerDevice"]] SERVICES: Sequence[Type[UpnpServerService]] ROUTES: Optional[Sequence[RouteDef]] = None def __init__( self, requester: UpnpRequester, base_uri: str, boot_id: int = 1, config_id: int = 1, ) -> None: """Initialize.""" services = [service_type(requester=requester) for service_type in self.SERVICES] embedded_devices = [ device_type( requester=requester, base_uri=base_uri, boot_id=boot_id, config_id=config_id, ) for device_type in self.EMBEDDED_DEVICES ] super().__init__( requester=requester, device_info=self.DEVICE_DEFINITION, services=services, embedded_devices=embedded_devices, ) self.base_uri = base_uri self.host = urlparse(base_uri).hostname self.boot_id = boot_id self.config_id = config_id class SsdpSearchResponder: """SSDP SEARCH responder.""" def __init__( self, device: UpnpServerDevice, source: Optional[AddressTupleVXType] = None, target: Optional[AddressTupleVXType] = None, options: Optional[Dict[str, Any]] = None, loop: Optional[asyncio.AbstractEventLoop] = None, ) -> None: """Init the ssdp search responder class.""" # pylint: disable=too-many-arguments,too-many-positional-arguments self.device = device self.source, self.target = determine_source_target(source, target) self.options = options or {} self._transport: Optional[DatagramTransport] = None self._response_transport: Optional[DatagramTransport] = None self._loop = loop or asyncio.get_running_loop() def _on_connect_response(self, transport: DatagramTransport) -> None: """Handle on connect for response.""" _LOGGER.debug("Connected to response transport: %s", transport) self._response_transport = transport def _on_connect(self, transport: DatagramTransport) -> None: """Handle on connect.""" self._transport = transport def _on_data( self, request_line: str, headers: CaseInsensitiveDict, ) -> None: """Handle data.""" # pylint: disable=too-many-branches assert self._transport if ( request_line != "M-SEARCH * HTTP/1.1" or headers.get_lower("man") != SSDP_DISCOVER ): return remote_addr = cast(AddressTupleVXType, headers.get_lower("_remote_addr")) debug = _LOGGER.isEnabledFor(logging.DEBUG) if debug: # pragma: no branch _LOGGER.debug( "Received M-SEARCH from: %s, headers: %s", remote_addr, headers ) mx_header = headers.get_lower("mx") delay = 0 if mx_header is not None: try: delay = min(5, int(mx_header)) if debug: # pragma: no branch _LOGGER.debug("Deferring response for %d seconds", delay) except ValueError: pass if not (responses := self._build_responses(headers)): return if delay: # The delay should be random between 0 and MX. # We use between 0.100 and MX-0.250 seconds to avoid # flooding the network with simultaneous responses. # # We do not set the upper limit to exactly MX seconds # because it might take up to 0.250 seconds to send the # response, and we want to avoid sending the response # after the MX timeout. self._loop.call_at( self._loop.time() + randrange(100, (delay * 1000) - 250) / 1000, self._send_responses, remote_addr, responses, ) self._send_responses(remote_addr, responses) def _build_responses(self, headers: CaseInsensitiveDict) -> List[bytes]: # Determine how we should respond, page 1.3.2 of UPnP-arch-DeviceArchitecture-v2.0. st_header: str = headers.get_lower("st", "") search_target = st_header.lower() responses: List[bytes] = [] if search_target == SSDP_ST_ALL: # 3 + 2d + k (d: embedded device, k: service) # global: ST: upnp:rootdevice # USN: uuid:device-UUID::upnp:rootdevice # per device : ST: uuid:device-UUID # USN: uuid:device-UUID # per device : ST: urn:schemas-upnp-org:device:deviceType:ver # USN: uuid:device-UUID::urn:schemas-upnp-org:device:deviceType:ver # per service: ST: urn:schemas-upnp-org:service:serviceType:ver # USN: uuid:device-UUID::urn:schemas-upnp-org:service:serviceType:ver all_devices = self.device.all_devices all_services = self.device.all_services responses.append(self._build_response_rootdevice()) responses.extend( self._build_responses_device_udn(device) for device in all_devices ) responses.extend( self._build_responses_device_type(device) for device in all_devices ) responses.extend( self._build_responses_service(service) for service in all_services ) elif search_target == SSDP_ST_ROOTDEVICE: responses.append(self._build_response_rootdevice()) elif matched_devices := self.device.get_devices_matching_udn(search_target): responses.extend( self._build_responses_device_udn(device) for device in matched_devices ) elif matched_devices := self._matched_devices_by_type(search_target): responses.extend( self._build_responses_device_type(device, search_target) for device in matched_devices ) elif matched_services := self._matched_services_by_type(search_target): responses.extend( self._build_responses_service(service, search_target) for service in matched_services ) if self.options.get(SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE): responses.append(self._build_response_rootdevice()) return responses @staticmethod def _match_type_versions(type_ver: str, search_target: str) -> bool: """Determine if any service/device type up to the max version matches search_target.""" # As per 1.3.2 of the UPnP Device Architecture spec, all device service types # must respond to and be backwards-compatible with older versions of the same type type_ver_lower: str = type_ver.lower() try: base, max_ver = type_ver_lower.rsplit(":", 1) max_ver_i = int(max_ver) for ver in range(max_ver_i + 1): if f"{base}:{ver}" == search_target: return True except ValueError: if type_ver_lower == search_target: return True return False def _matched_devices_by_type(self, search_target: str) -> List[UpnpDevice]: """Get matched devices by device type.""" return [ device for device in self.device.all_devices if self._match_type_versions(device.device_type, search_target) ] def _matched_services_by_type(self, search_target: str) -> List[UpnpService]: """Get matched services by service type.""" return [ service for service in self.device.all_services if self._match_type_versions(service.service_type, search_target) ] async def async_start(self) -> None: """Start.""" _LOGGER.debug("Start listening for search requests") # Create response socket/protocol. response_sock, _source, _target = get_ssdp_socket(self.source, self.target) await self._loop.create_datagram_endpoint( lambda: SsdpProtocol( self._loop, on_connect=self._on_connect_response, ), sock=response_sock, ) # Create listening socket/protocol. sock, _source, _target = get_ssdp_socket(self.source, self.target) address = ("", self.target[1]) _LOGGER.debug("Binding socket, socket: %s, address: %s", sock, address) sock.bind(address) await self._loop.create_datagram_endpoint( lambda: SsdpProtocol( self._loop, on_connect=self._on_connect, on_data=self._on_data, ), sock=sock, ) async def async_stop(self) -> None: """Stop listening for advertisements.""" assert self._transport _LOGGER.debug("Stop listening for SEARCH requests") self._transport.close() def _build_response_rootdevice(self) -> bytes: """Send root device response.""" return self._build_response( "upnp:rootdevice", f"{self.device.udn}::upnp:rootdevice" ) def _build_responses_device_udn(self, device: UpnpDevice) -> bytes: """Send device responses for UDN.""" return self._build_response(device.udn, f"{self.device.udn}") def _build_responses_device_type( self, device: UpnpDevice, device_type: Optional[str] = None ) -> bytes: """Send device responses for device type.""" return self._build_response( device_type or device.device_type, f"{self.device.udn}::{device.device_type}", ) def _build_responses_service( self, service: UpnpService, service_type: Optional[str] = None ) -> bytes: """Send service responses.""" return self._build_response( service_type or service.service_type, f"{self.device.udn}::{service.service_type}", ) def _build_response( self, service_type: str, unique_service_name: str, ) -> bytes: """Send a response.""" return build_ssdp_packet( "HTTP/1.1 200 OK", { "CACHE-CONTROL": HEADER_CACHE_CONTROL, "DATE": format_date_time(time.time()), "SERVER": HEADER_SERVER, "ST": service_type, "USN": unique_service_name, "EXT": "", "LOCATION": f"{self.device.base_uri}{self.device.device_url}", "BOOTID.UPNP.ORG": str(self.device.boot_id), "CONFIGID.UPNP.ORG": str(self.device.config_id), }, ) def _send_responses( self, remote_addr: AddressTupleVXType, responses: List[bytes] ) -> None: """Send responses.""" assert self._response_transport if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no branch sock: Optional[socket.socket] = self._response_transport.get_extra_info( "socket" ) _LOGGER.debug( "Sending SSDP packet, transport: %s, socket: %s, target: %s", self._response_transport, sock, remote_addr, ) _LOGGER_TRAFFIC_SSDP.debug( "Sending SSDP packets, target: %s, data: %s", remote_addr, responses ) for response in responses: try: protocol = cast(SsdpProtocol, self._response_transport.get_protocol()) protocol.send_ssdp_packet(response, remote_addr) except OSError as err: _LOGGER.debug("Error sending response: %s", err) def _build_advertisements( target: AddressTupleVXType, root_device: UpnpServerDevice, nts: NotificationSubType = NotificationSubType.SSDP_ALIVE, ) -> List[CaseInsensitiveDict]: """Build advertisements to be sent for a UpnpDevice.""" # 3 + 2d + k (d: embedded device, k: service) # global: ST: upnp:rootdevice # USN: uuid:device-UUID::upnp:rootdevice # per device : ST: uuid:device-UUID # USN: uuid:device-UUID # per device : ST: urn:schemas-upnp-org:device:deviceType:ver # USN: uuid:device-UUID::urn:schemas-upnp-org:device:deviceType:ver # per service: ST: urn:schemas-upnp-org:service:serviceType:ver # USN: uuid:device-UUID::urn:schemas-upnp-org:service:serviceType:ver advertisements: List[CaseInsensitiveDict] = [] host = ( f"[{target[0]}]:{target[1]}" if is_ipv6_address(target) else f"{target[0]}:{target[1]}" ) base_headers = { "NTS": nts.value, "HOST": host, "CACHE-CONTROL": HEADER_CACHE_CONTROL, "SERVER": HEADER_SERVER, "BOOTID.UPNP.ORG": str(root_device.boot_id), "CONFIGID.UPNP.ORG": str(root_device.config_id), "LOCATION": f"{root_device.base_uri}{root_device.device_url}", } # root device advertisements.append( CaseInsensitiveDict( base_headers, NT="upnp:rootdevice", USN=f"{root_device.udn}::upnp:rootdevice", ) ) for device in root_device.all_devices: advertisements.append( CaseInsensitiveDict( base_headers, NT=f"{device.udn}", USN=f"{device.udn}", ) ) advertisements.append( CaseInsensitiveDict( base_headers, NT=f"{device.device_type}", USN=f"{device.udn}::{device.device_type}", ) ) for service in root_device.all_services: advertisements.append( CaseInsensitiveDict( base_headers, NT=f"{service.service_type}", USN=f"{service.device.udn}::{service.service_type}", ) ) return advertisements class SsdpAdvertisementAnnouncer: """SSDP Advertisement announcer.""" # pylint: disable=too-many-instance-attributes ANNOUNCE_INTERVAL = timedelta(seconds=30) def __init__( self, device: UpnpServerDevice, source: Optional[AddressTupleVXType] = None, target: Optional[AddressTupleVXType] = None, options: Optional[Dict[str, Any]] = None, loop: Optional[asyncio.AbstractEventLoop] = None, ) -> None: """Init the ssdp search responder class.""" # pylint: disable=too-many-arguments,too-many-positional-arguments self.device = device self.source, self.target = determine_source_target(source, target) self.options = options or {} self._loop = loop or asyncio.get_running_loop() self._transport: Optional[DatagramTransport] = None advertisements = _build_advertisements(self.target, device) self._advertisements = cycle(advertisements) self._cancel_announce: Optional[asyncio.TimerHandle] = None def _on_connect(self, transport: DatagramTransport) -> None: """Handle on connect.""" self._transport = transport async def async_start(self) -> None: """Start.""" _LOGGER.debug("Start advertisements announcer") # Construct a socket for use with this pairs of endpoints. sock, _source, _target = get_ssdp_socket(self.source, self.target) if sys.platform.startswith("win32"): address = self.source _LOGGER.debug("Binding socket, socket: %s, address: %s", sock, address) sock.bind(address) # Create protocol and send discovery packet. await self._loop.create_datagram_endpoint( lambda: SsdpProtocol( self._loop, on_connect=self._on_connect, ), sock=sock, ) await self.async_wait_for_transport_protocol() # Announce and reschedule self. self._announce_next() async def async_stop(self) -> None: """Stop listening for advertisements.""" assert self._transport sock: Optional[socket.socket] = self._transport.get_extra_info("socket") _LOGGER.debug( "Stop advertisements announcer, transport: %s, socket: %s", self._transport, sock, ) if self._cancel_announce is not None: self._cancel_announce.cancel() self._send_byebyes() self._transport.close() async def async_wait_for_transport_protocol(self) -> None: """Wait for the protocol to become available.""" for _ in range(0, 5): if ( self._transport is not None and self._transport.get_protocol() is not None ): break await asyncio.sleep(0.1) else: raise UpnpError("Failed to get protocol") def _announce_next(self) -> None: """Announce next advertisement.""" _LOGGER.debug("Announcing") assert self._transport protocol = cast(SsdpProtocol, self._transport.get_protocol()) start_line = "NOTIFY * HTTP/1.1" headers = next(self._advertisements) packet = build_ssdp_packet(start_line, headers) _LOGGER.debug( "Sending advertisement, NTS: %s, NT: %s, USN: %s", headers["NTS"], headers["NT"], headers["USN"], ) protocol.send_ssdp_packet(packet, self.target) # Reschedule self. self._cancel_announce = self._loop.call_later( SsdpAdvertisementAnnouncer.ANNOUNCE_INTERVAL.total_seconds(), self._announce_next, ) def _send_byebyes(self) -> None: """Send ssdp:byebye.""" assert self._transport start_line = "NOTIFY * HTTP/1.1" advertisements = _build_advertisements( self.target, self.device, NotificationSubType.SSDP_BYEBYE ) for headers in advertisements: packet = build_ssdp_packet(start_line, headers) protocol = cast(SsdpProtocol, self._transport.get_protocol()) _LOGGER.debug( "Sending advertisement, NTS: %s, NT: %s, USN: %s", headers["NTS"], headers["NT"], headers["USN"], ) protocol.send_ssdp_packet(packet, self.target) class UpnpXmlSerializer: """Helper class to create device/service description from UpnpDevice/UpnpService.""" # pylint: disable=too-few-public-methods @classmethod def to_xml(cls, thing: Union[UpnpDevice, UpnpService]) -> ET.Element: """Convert thing to XML.""" if isinstance(thing, UpnpDevice): return cls._device_to_xml(thing) if isinstance(thing, UpnpService): return cls._service_to_xml(thing) raise NotImplementedError() @classmethod def _device_to_xml(cls, device: UpnpDevice) -> ET.Element: """Convert device to device description XML.""" root_el = ET.Element("root", xmlns="urn:schemas-upnp-org:device-1-0") spec_version_el = ET.SubElement(root_el, "specVersion") ET.SubElement(spec_version_el, "major").text = "1" ET.SubElement(spec_version_el, "minor").text = "0" device_el = cls._device_to_xml_bare(device) root_el.append(device_el) return root_el @classmethod def _device_to_xml_bare(cls, device: UpnpDevice) -> ET.Element: """Convert device to XML, without the root-element.""" device_el = ET.Element("device", xmlns="urn:schemas-upnp-org:device-1-0") ET.SubElement(device_el, "deviceType").text = device.device_type ET.SubElement(device_el, "friendlyName").text = device.friendly_name ET.SubElement(device_el, "manufacturer").text = device.manufacturer ET.SubElement(device_el, "manufacturerURL").text = device.manufacturer_url ET.SubElement(device_el, "modelDescription").text = device.model_description ET.SubElement(device_el, "modelName").text = device.model_name ET.SubElement(device_el, "modelNumber").text = device.model_number ET.SubElement(device_el, "modelURL").text = device.model_url ET.SubElement(device_el, "serialNumber").text = device.serial_number ET.SubElement(device_el, "UDN").text = device.udn ET.SubElement(device_el, "UPC").text = device.upc ET.SubElement(device_el, "presentationURL").text = device.presentation_url icon_list_el = ET.SubElement(device_el, "iconList") for icon in device.icons: icon_el = ET.SubElement(icon_list_el, "icon") ET.SubElement(icon_el, "mimetype").text = icon.mimetype ET.SubElement(icon_el, "width").text = str(icon.width) ET.SubElement(icon_el, "height").text = str(icon.height) ET.SubElement(icon_el, "depth").text = str(icon.depth) ET.SubElement(icon_el, "url").text = icon.url service_list_el = ET.SubElement(device_el, "serviceList") for service in device.services.values(): service_el = ET.SubElement(service_list_el, "service") ET.SubElement(service_el, "serviceType").text = service.service_type ET.SubElement(service_el, "serviceId").text = service.service_id ET.SubElement(service_el, "controlURL").text = service.control_url ET.SubElement(service_el, "eventSubURL").text = service.event_sub_url ET.SubElement(service_el, "SCPDURL").text = service.scpd_url device_list_el = ET.SubElement(device_el, "deviceList") for embedded_device in device.embedded_devices.values(): embedded_device_el = cls._device_to_xml_bare(embedded_device) device_list_el.append(embedded_device_el) return device_el @classmethod def _service_to_xml(cls, service: UpnpService) -> ET.Element: """Convert service to service description XML.""" scpd_el = ET.Element("scpd", xmlns="urn:schemas-upnp-org:service-1-0") spec_version_el = ET.SubElement(scpd_el, "specVersion") ET.SubElement(spec_version_el, "major").text = "1" ET.SubElement(spec_version_el, "minor").text = "0" action_list_el = ET.SubElement(scpd_el, "actionList") for action in service.actions.values(): action_el = cls._action_to_xml(action) action_list_el.append(action_el) state_table_el = ET.SubElement(scpd_el, "serviceStateTable") for state_var in service.state_variables.values(): state_var_el = cls._state_variable_to_xml(state_var) state_table_el.append(state_var_el) return scpd_el @classmethod def _action_to_xml(cls, action: UpnpAction) -> ET.Element: """Convert action to service description XML.""" action_el = ET.Element("action") ET.SubElement(action_el, "name").text = action.name if action.arguments: arg_list_el = ET.SubElement(action_el, "argumentList") for arg in action.in_arguments(): arg_el = cls._action_argument_to_xml(arg) arg_list_el.append(arg_el) for arg in action.out_arguments(): arg_el = cls._action_argument_to_xml(arg) arg_list_el.append(arg_el) return action_el @classmethod def _action_argument_to_xml(cls, argument: UpnpAction.Argument) -> ET.Element: """Convert action argument to service description XML.""" arg_el = ET.Element("argument") ET.SubElement(arg_el, "name").text = argument.name ET.SubElement(arg_el, "direction").text = argument.direction ET.SubElement(arg_el, "relatedStateVariable").text = ( argument.related_state_variable.name ) return arg_el @classmethod def _state_variable_to_xml(cls, state_variable: UpnpStateVariable) -> ET.Element: """Convert state variable to service description XML.""" state_var_el = ET.Element( "stateVariable", sendEvents="yes" if state_variable.send_events else "no" ) ET.SubElement(state_var_el, "name").text = state_variable.name ET.SubElement(state_var_el, "dataType").text = state_variable.data_type if state_variable.allowed_values: value_list_el = ET.SubElement(state_var_el, "allowedValueList") for allowed_value in state_variable.allowed_values: ET.SubElement(value_list_el, "allowedValue").text = str(allowed_value) if None not in (state_variable.min_value, state_variable.max_value): value_range_el = ET.SubElement(state_var_el, "allowedValueRange") ET.SubElement(value_range_el, "minimum").text = str( state_variable.min_value ) ET.SubElement(value_range_el, "maximum").text = str( state_variable.max_value ) if state_variable.default_value is not None: ET.SubElement(state_var_el, "defaultValue").text = str( state_variable.default_value ) return state_var_el def callable_action( name: str, in_args: Mapping[str, str], out_args: Mapping[str, str] ) -> Callable: """Declare method as a callable UpnpAction.""" def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) setattr(wrapper, "__upnp_action__", [name, in_args, out_args]) return wrapper return decorator async def _parse_action_body( service: UpnpServerService, request: Request ) -> Tuple[str, Dict[str, Any]]: """Parse action body.""" # Parse call. soap_action = request.headers.get("SOAPAction", "").strip('"') try: _, action_name = soap_action.split("#") data = await request.text() root_el: ET.Element = DET.fromstring(data) body_el = root_el.find("s:Body", NAMESPACES) assert body_el rpc_el = body_el[0] except Exception as exc: raise HTTPBadRequest(reason="InvalidSoap") from exc if action_name not in service.actions: raise HTTPBadRequest(reason="InvalidAction") kwargs: Dict[str, Any] = {} action = service.action(action_name) for arg in rpc_el: action_arg = action.argument(arg.tag, direction="in") if action_arg is None: raise HTTPBadRequest(reason="InvalidActionArgument") state_var = action_arg.related_state_variable kwargs[arg.tag] = state_var.coerce_python(arg.text or "") return action_name, kwargs def _create_action_response( service: UpnpServerService, action_name: str, result: Dict[str, Any] ) -> Response: """Create action call response.""" envelope_el = ET.Element( "s:Envelope", attrib={ "xmlns:s": NAMESPACES["s"], "s:encodingStyle": NAMESPACES["es"], }, ) body_el = ET.SubElement(envelope_el, "s:Body") response_el = ET.SubElement( body_el, f"st:{action_name}Response", attrib={"xmlns:st": service.service_type} ) out_state_vars = { var.name: var.related_state_variable for var in service.actions[action_name].out_arguments() } for key, value in result.items(): if isinstance(value, UpnpStateVariable): ET.SubElement(response_el, key).text = value.upnp_value else: template_var = out_state_vars[key] template_var.validate_value(value) ET.SubElement(response_el, key).text = template_var.coerce_upnp(value) return Response( content_type="text/xml", charset="utf-8", body=ET.tostring(envelope_el, encoding="utf-8", xml_declaration=True), ) def _create_error_action_response( exception: UpnpError, ) -> Response: """Create action call response.""" envelope_el = ET.Element( "s:Envelope", attrib={ "xmlns:s": NAMESPACES["s"], "s:encodingStyle": NAMESPACES["es"], }, ) body_el = ET.SubElement(envelope_el, "s:Body") fault_el = ET.SubElement(body_el, "s:Fault") ET.SubElement(fault_el, "faultcode").text = "s:Client" ET.SubElement(fault_el, "faultstring").text = "UPnPError" detail_el = ET.SubElement(fault_el, "detail") error_el = ET.SubElement( detail_el, "UPnPError", xmlns="urn:schemas-upnp-org:control-1-0" ) error_code = ( exception.error_code or UpnpActionErrorCode.ACTION_FAILED.value if isinstance(exception, UpnpActionError) else 402 if isinstance(exception, UpnpValueError) else 501 ) ET.SubElement(error_el, "errorCode").text = str(error_code) ET.SubElement(error_el, "errorDescription").text = "Action Failed" return Response( status=500, content_type="text/xml", charset="utf-8", body=ET.tostring(envelope_el, encoding="utf-8", xml_declaration=True), ) async def action_handler(service: UpnpServerService, request: Request) -> Response: """Handle action.""" action_name, kwargs = await _parse_action_body(service, request) # Do call. try: call_result = await service.async_handle_action(action_name, **kwargs) except UpnpValueError as exc: return _create_error_action_response(exc) except UpnpActionError as exc: return _create_error_action_response(exc) return _create_action_response(service, action_name, call_result) async def subscribe_handler(service: UpnpServerService, request: Request) -> Response: """SUBSCRIBE handler.""" callback_url = request.headers.get("CALLBACK", None) timeout = request.headers.get("TIMEOUT", None) sid = request.headers.get("SID", None) timeout_val = None if timeout is not None: try: timeout_val = int(timeout.lower().replace("second-", "")) except ValueError: return Response(status=400) subscriber = None if sid: subscriber = service.get_subscriber(sid) if subscriber: subscriber.timeout = timeout_val else: if callback_url: # callback url is specified as # remove the outside <> callback_url = callback_url[1:-1] subscriber = EventSubscriber(callback_url, timeout_val) if not subscriber: return Response(status=404) headers = { "DATE": format_date_time(mktime(datetime.now().timetuple())), "SERVER": HEADER_SERVER, "SID": subscriber.uuid, "TIMEOUT": str(subscriber.timeout), } resp = Response(status=200, headers=headers) if sid is None: # this is an initial subscription. Need to send state-vars # AFTER response completion await resp.prepare(request) await resp.write_eof() await service.async_send_events(subscriber) service.add_subscriber(subscriber) return resp async def unsubscribe_handler(service: UpnpServerService, request: Request) -> Response: """UNSUBSCRIBE handler.""" sid = request.headers.get("SID", None) if sid: if service.del_subscriber(sid): return Response(status=200) return Response(status=412) async def to_xml( thing: Union[UpnpServerDevice, UpnpServerService], _request: Request ) -> Response: """Construct device/service description.""" serializer = UpnpXmlSerializer() thing_el = serializer.to_xml(thing) encoding = "utf-8" thing_xml = ET.tostring(thing_el, encoding=encoding, xml_declaration=True) return Response(content_type="text/xml", charset=encoding, body=thing_xml) def create_state_var( data_type: str, *, allowed: Optional[List[str]] = None, allowed_range: Optional[Mapping[str, Optional[str]]] = None, default: Optional[str] = None, ) -> StateVariableTypeInfo: """Create state variables.""" return StateVariableTypeInfo( data_type=data_type, data_type_mapping=STATE_VARIABLE_TYPE_MAPPING[data_type], default_value=default, allowed_value_range=allowed_range or {}, allowed_values=allowed, xml=ET.Element("server_stateVariable"), ) def create_event_var( data_type: str, *, allowed: Optional[List[str]] = None, allowed_range: Optional[Mapping[str, Optional[str]]] = None, default: Optional[str] = None, max_rate: Optional[float] = None, ) -> StateVariableTypeInfo: """Create event variables.""" return cast( StateVariableTypeInfo, EventableStateVariableTypeInfo( data_type=data_type, data_type_mapping=STATE_VARIABLE_TYPE_MAPPING[data_type], default_value=default, allowed_value_range=allowed_range or {}, allowed_values=allowed, max_rate=max_rate, xml=ET.Element("server_stateVariable"), ), ) class UpnpServer: """UPnP Server.""" # pylint: disable=too-many-instance-attributes def __init__( self, server_device: Type[UpnpServerDevice], source: AddressTupleVXType, target: Optional[AddressTupleVXType] = None, http_port: Optional[int] = None, boot_id: int = 1, config_id: int = 1, options: Optional[Dict[str, Any]] = None, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments,too-many-positional-arguments self.server_device = server_device self.source, self.target = determine_source_target(source, target) self.http_port = http_port self.boot_id = boot_id self.config_id = config_id self.options = options or {} self.base_uri: Optional[str] = None self._device: Optional[UpnpServerDevice] = None self._site: Optional[TCPSite] = None self._search_responder: Optional[SsdpSearchResponder] = None self._advertisement_announcer: Optional[SsdpAdvertisementAnnouncer] = None async def async_start(self) -> None: """Start.""" self._create_device() await self._async_start_http_server() await self._async_start_ssdp() def _create_device(self) -> None: """Create device.""" requester = AiohttpRequester() is_ipv6 = ":" in self.source[0] self.base_uri = ( f"http://[{self.source[0]}]:{self.http_port}" if is_ipv6 else f"http://{self.source[0]}:{self.http_port}" ) self._device = self.server_device( requester, self.base_uri, self.boot_id, self.config_id ) async def _async_start_http_server(self) -> None: """Start http server.""" assert self._device # Build app. app = Application() app.router.add_get(self._device.device_url, partial(to_xml, self._device)) for service in self._device.all_services: service = cast(UpnpServerService, service) app.router.add_get( service.SERVICE_DEFINITION.scpd_url, partial(to_xml, service) ) app.router.add_post( service.SERVICE_DEFINITION.control_url, partial(action_handler, service) ) app.router.add_route( "SUBSCRIBE", service.SERVICE_DEFINITION.event_sub_url, partial(subscribe_handler, service), ) app.router.add_route( "UNSUBSCRIBE", service.SERVICE_DEFINITION.event_sub_url, partial(unsubscribe_handler, service), ) if self._device.ROUTES: app.router.add_routes(self._device.ROUTES) # Create AppRunner. runner = AppRunner(app, access_log=_LOGGER_TRAFFIC_UPNP) await runner.setup() # Launch TCP handler. is_ipv6 = ":" in self.source[0] host = f"{self.source[0]}%{self.source[3]}" if is_ipv6 else self.source[0] # type: ignore self._site = TCPSite(runner, host, self.http_port, reuse_address=True) await self._site.start() assert self._device _LOGGER.debug( "Device listening at %s%s", self._site.name, self._device.device_url ) async def _async_start_ssdp(self) -> None: """Start SSDP handling.""" _LOGGER.debug( "Starting SSDP handling, source: %s, target: %s", self.source, self.target ) assert self._device self._search_responder = SsdpSearchResponder( self._device, source=self.source, target=self.target, options=self.options.get(SSDP_SEARCH_RESPONDER_OPTIONS), ) self._advertisement_announcer = SsdpAdvertisementAnnouncer( self._device, source=self.source, target=self.target, options=self.options.get(SSDP_ADVERTISEMENT_ANNOUNCER_OPTIONS), ) await self._search_responder.async_start() await self._advertisement_announcer.async_start() async def async_stop(self) -> None: """Stop server.""" await self._async_stop_ssdp() await self._async_stop_http_server() async def _async_stop_ssdp(self) -> None: """Stop SSDP handling.""" if self._advertisement_announcer: await self._advertisement_announcer.async_stop() if self._search_responder: await self._search_responder.async_stop() async def _async_stop_http_server(self) -> None: """Stop HTTP server.""" if self._site: await self._site.stop() async_upnp_client-0.44.0/async_upnp_client/ssdp.py000066400000000000000000000372171477256211100223740ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.ssdp module.""" import logging import socket import sys from asyncio import BaseTransport, DatagramProtocol, DatagramTransport from asyncio.events import AbstractEventLoop from datetime import datetime from functools import lru_cache from ipaddress import IPv4Address, IPv6Address, ip_address from typing import ( TYPE_CHECKING, Any, Callable, Coroutine, Dict, Optional, Tuple, Union, cast, ) from urllib.parse import urlsplit, urlunsplit from aiohttp.http_exceptions import InvalidHeader from aiohttp.http_parser import HeadersParser from multidict import CIMultiDictProxy from async_upnp_client.const import ( AddressTupleV4Type, AddressTupleV6Type, AddressTupleVXType, IPvXAddress, SsdpHeaders, UniqueDeviceName, ) from async_upnp_client.exceptions import UpnpError from async_upnp_client.utils import CaseInsensitiveDict, lowerstr SSDP_PORT = 1900 SSDP_IP_V4 = "239.255.255.250" SSDP_IP_V6_LINK_LOCAL = "FF02::C" SSDP_IP_V6_SITE_LOCAL = "FF05::C" SSDP_IP_V6_ORGANISATION_LOCAL = "FF08::C" SSDP_IP_V6_GLOBAL = "FF0E::C" SSDP_IP_V6 = SSDP_IP_V6_LINK_LOCAL SSDP_TARGET_V4 = (SSDP_IP_V4, SSDP_PORT) SSDP_TARGET_V6 = ( SSDP_IP_V6, SSDP_PORT, 0, 0, ) # Replace the last item with your scope_id! SSDP_TARGET = SSDP_TARGET_V4 SSDP_ST_ALL = "ssdp:all" SSDP_ST_ROOTDEVICE = "upnp:rootdevice" SSDP_MX = 4 SSDP_DISCOVER = '"ssdp:discover"' _LOGGER = logging.getLogger(__name__) _LOGGER_TRAFFIC_SSDP = logging.getLogger("async_upnp_client.traffic.ssdp") def get_host_string(addr: AddressTupleVXType) -> str: """Construct host string from address tuple.""" if len(addr) == 4: if TYPE_CHECKING: addr = cast(AddressTupleV6Type, addr) if addr[3]: return f"{addr[0]}%{addr[3]}" return addr[0] def get_host_port_string(addr: AddressTupleVXType) -> str: """Return a properly escaped host port pair.""" host = get_host_string(addr) if ":" in host: return f"[{host}]:{addr[1]}" return f"{host}:{addr[1]}" @lru_cache(maxsize=256) def get_adjusted_url(url: str, addr: AddressTupleVXType) -> str: """Adjust a url with correction for link local scope.""" if len(addr) < 4: return url if TYPE_CHECKING: addr = cast(AddressTupleV6Type, addr) if not addr[3]: return url data = urlsplit(url) assert data.hostname try: address = ip_address(data.hostname) except ValueError: return url if not address.is_link_local: return url netloc = f"[{data.hostname}%{addr[3]}]" if data.port: netloc += f":{data.port}" return urlunsplit(data._replace(netloc=netloc)) def is_ipv4_address(addr: AddressTupleVXType) -> bool: """Test if addr is a IPv4 tuple.""" return len(addr) == 2 def is_ipv6_address(addr: AddressTupleVXType) -> bool: """Test if addr is a IPv6 tuple.""" return len(addr) == 4 def build_ssdp_packet(status_line: str, headers: SsdpHeaders) -> bytes: """Construct a SSDP packet.""" headers_str = "\r\n".join([f"{key}:{value}" for key, value in headers.items()]) return f"{status_line}\r\n{headers_str}\r\n\r\n".encode() def build_ssdp_search_packet( ssdp_target: AddressTupleVXType, ssdp_mx: int, ssdp_st: str ) -> bytes: """Construct a SSDP M-SEARCH packet.""" request_line = "M-SEARCH * HTTP/1.1" headers = { "HOST": f"{get_host_port_string(ssdp_target)}", "MAN": SSDP_DISCOVER, "MX": f"{ssdp_mx}", "ST": f"{ssdp_st}", } return build_ssdp_packet(request_line, headers) @lru_cache(maxsize=128) def is_valid_ssdp_packet(data: bytes) -> bool: """Check if data is a valid and decodable packet.""" return ( bool(data) and b"\n" in data and ( data.startswith(b"NOTIFY * HTTP/1.1") or data.startswith(b"M-SEARCH * HTTP/1.1") or data.startswith(b"HTTP/1.1 200 OK") ) ) # No longer used internally, but left for backwards compatibility def udn_from_headers( headers: Union[CIMultiDictProxy, CaseInsensitiveDict] ) -> Optional[UniqueDeviceName]: """Get UDN from USN in headers.""" usn: str = headers.get("usn", "") return udn_from_usn(usn) @lru_cache(maxsize=128) def udn_from_usn(usn: str) -> Optional[UniqueDeviceName]: """Get UDN from USN in headers.""" if usn.lower().startswith("uuid:"): return usn.partition("::")[0] return None @lru_cache(maxsize=128) def _cached_header_parse( data: bytes, ) -> Tuple[CIMultiDictProxy[str], str, Optional[UniqueDeviceName]]: """Cache parsing headers. SSDP discover packets frequently end up being sent multiple times on multiple interfaces. We can avoid parsing the sames ones over and over again with a simple lru_cache. """ lines = data.replace(b"\r\n", b"\n").split(b"\n") # request_line request_line = lines[0].strip().decode() if lines and lines[-1] != b"": lines.append(b"") parsed_headers, _ = HeadersParser().parse_headers(lines) usn = parsed_headers.get("usn") udn = udn_from_usn(usn) if usn else None return parsed_headers, request_line, udn LOWER__TIMESTAMP = lowerstr("_timestamp") LOWER__HOST = lowerstr("_host") LOWER__PORT = lowerstr("_port") LOWER__LOCAL_ADDR = lowerstr("_local_addr") LOWER__REMOTE_ADDR = lowerstr("_remote_addr") LOWER__UDN = lowerstr("_udn") LOWER__LOCATION_ORIGINAL = lowerstr("_location_original") LOWER_LOCATION = lowerstr("location") @lru_cache(maxsize=512) def _cached_decode_ssdp_packet( data: bytes, remote_addr_without_port: AddressTupleVXType, ) -> Tuple[str, CaseInsensitiveDict]: """Cache decoding SSDP packets.""" parsed_headers, request_line, udn = _cached_header_parse(data) # own data extra: Dict[str, Any] = {LOWER__HOST: get_host_string(remote_addr_without_port)} if udn: extra[LOWER__UDN] = udn # adjust some headers location = parsed_headers.get("location", "") if location.strip(): extra[LOWER__LOCATION_ORIGINAL] = location extra[LOWER_LOCATION] = get_adjusted_url(location, remote_addr_without_port) headers = CaseInsensitiveDict(parsed_headers, **extra) return request_line, headers def decode_ssdp_packet( data: bytes, local_addr: Optional[AddressTupleVXType], remote_addr: AddressTupleVXType, ) -> Tuple[str, CaseInsensitiveDict]: """Decode a message.""" # We want to use remote_addr_without_port as the cache # key since nothing in _cached_decode_ssdp_packet cares # about the port if len(remote_addr) == 4: if TYPE_CHECKING: remote_addr = cast(AddressTupleV6Type, remote_addr) addr, port, flow, scope = remote_addr remote_addr_without_port: AddressTupleVXType = addr, 0, flow, scope else: if TYPE_CHECKING: remote_addr = cast(AddressTupleV4Type, remote_addr) addr, port = remote_addr remote_addr_without_port = remote_addr[0], 0 request_line, headers = _cached_decode_ssdp_packet(data, remote_addr_without_port) return request_line, headers.combine_lower_dict( { LOWER__TIMESTAMP: datetime.now(), LOWER__REMOTE_ADDR: remote_addr, LOWER__PORT: port, LOWER__LOCAL_ADDR: local_addr, } ) class SsdpProtocol(DatagramProtocol): """SSDP Protocol.""" def __init__( self, loop: AbstractEventLoop, async_on_connect: Optional[ Callable[[DatagramTransport], Coroutine[Any, Any, None]] ] = None, on_connect: Optional[Callable[[DatagramTransport], None]] = None, async_on_data: Optional[ Callable[[str, CaseInsensitiveDict], Coroutine[Any, Any, None]] ] = None, on_data: Optional[Callable[[str, CaseInsensitiveDict], None]] = None, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments,too-many-positional-arguments self.loop = loop self.async_on_connect = async_on_connect self.on_connect = on_connect self.async_on_data = async_on_data self.on_data = on_data self.transport: Optional[DatagramTransport] = None self.local_addr: Optional[AddressTupleVXType] = None def connection_made(self, transport: BaseTransport) -> None: """Handle connection made.""" self.transport = cast(DatagramTransport, transport) sock: Optional[socket.socket] = transport.get_extra_info("socket") self.local_addr = sock.getsockname() if sock is not None else None _LOGGER.debug( "Connection made, transport: %s, socket: %s", transport, sock, ) if self.async_on_connect: coro = self.async_on_connect(self.transport) self.loop.create_task(coro) if self.on_connect: self.on_connect(self.transport) def datagram_received(self, data: bytes, addr: AddressTupleVXType) -> None: """Handle a discovery-response.""" _LOGGER_TRAFFIC_SSDP.debug("Received packet from %s: %s", addr, data) assert self.transport if is_valid_ssdp_packet(data): try: request_line, headers = decode_ssdp_packet(data, self.local_addr, addr) except InvalidHeader as exc: _LOGGER.debug("Ignoring received packet with invalid headers: %s", exc) return if self.async_on_data: coro = self.async_on_data(request_line, headers) self.loop.create_task(coro) if self.on_data: self.on_data(request_line, headers) def error_received(self, exc: Exception) -> None: """Handle an error.""" sock: Optional[socket.socket] = ( self.transport.get_extra_info("socket") if self.transport else None ) _LOGGER.error( "Received error: %s, transport: %s, socket: %s", exc, self.transport, sock ) def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" if not _LOGGER.isEnabledFor(logging.DEBUG): return assert self.transport sock: Optional[socket.socket] = self.transport.get_extra_info("socket") _LOGGER.debug( "Lost connection, error: %s, transport: %s, socket: %s", exc, self.transport, sock, ) def send_ssdp_packet(self, packet: bytes, target: AddressTupleVXType) -> None: """Send a SSDP packet.""" assert self.transport if _LOGGER.isEnabledFor(logging.DEBUG): sock: Optional[socket.socket] = self.transport.get_extra_info("socket") _LOGGER.debug( "Sending SSDP packet, transport: %s, socket: %s, target: %s", self.transport, sock, target, ) if _LOGGER_TRAFFIC_SSDP.isEnabledFor(logging.DEBUG): _LOGGER_TRAFFIC_SSDP.debug( "Sending SSDP packet, target: %s, data: %s", target, packet ) self.transport.sendto(packet, target) def determine_source_target( source: Optional[AddressTupleVXType] = None, target: Optional[AddressTupleVXType] = None, ) -> Tuple[AddressTupleVXType, AddressTupleVXType]: """Determine source and target.""" if source is None and target is None: return ("0.0.0.0", 0), (SSDP_IP_V4, SSDP_PORT) if source is not None and target is None: if len(source) == 2: return source, (SSDP_IP_V4, SSDP_PORT) source = cast(AddressTupleV6Type, source) return source, (SSDP_IP_V6, SSDP_PORT, 0, source[3]) if source is None and target is not None: if len(target) == 2: return ( "0.0.0.0", 0, ), target target = cast(AddressTupleV6Type, target) return ("::", 0, 0, target[3]), target if source is not None and target is not None and len(source) != len(target): raise UpnpError("Source and target do not match protocol") return cast(AddressTupleVXType, source), cast(AddressTupleVXType, target) def fix_ipv6_address_scope_id( address: Optional[AddressTupleVXType], ) -> Optional[AddressTupleVXType]: """Fix scope_id for an IPv6 address, if needed.""" if address is None or is_ipv4_address(address): return address ip_str = address[0] if "%" not in ip_str: # Nothing to fix. return address address = cast(AddressTupleV6Type, address) idx = ip_str.index("%") try: ip_scope_id = int(ip_str[idx + 1 :]) except ValueError: pass scope_id = address[3] new_scope_id = ip_scope_id if not scope_id and ip_scope_id else address[3] new_ip = ip_str[:idx] return ( new_ip, address[1], address[2], new_scope_id, ) def ip_port_from_address_tuple( address_tuple: AddressTupleVXType, ) -> Tuple[IPvXAddress, int]: """Get IPvXAddress from AddressTupleVXType.""" if len(address_tuple) == 4: address_tuple = cast(AddressTupleV6Type, address_tuple) if "%" in address_tuple[0]: return IPv6Address(address_tuple[0]), address_tuple[1] return IPv6Address(f"{address_tuple[0]}%{address_tuple[3]}"), address_tuple[1] return IPv4Address(address_tuple[0]), address_tuple[1] def get_ssdp_socket( source: AddressTupleVXType, target: AddressTupleVXType, ) -> Tuple[socket.socket, AddressTupleVXType, AddressTupleVXType]: """Create a socket to listen on.""" # Ensure a proper IPv6 source/target. if is_ipv6_address(source): source = cast(AddressTupleV6Type, source) if not source[3]: raise UpnpError(f"Source missing scope_id, source: {source}") if is_ipv6_address(target): target = cast(AddressTupleV6Type, target) if not target[3]: raise UpnpError(f"Target missing scope_id, target: {target}") target_ip, target_port = ip_port_from_address_tuple(target) target_info = socket.getaddrinfo( str(target_ip), target_port, type=socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP, )[0] source_ip, source_port = ip_port_from_address_tuple(source) source_info = socket.getaddrinfo( str(source_ip), source_port, type=socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP )[0] _LOGGER.debug("Creating socket, source: %s, target: %s", source_info, target_info) # create socket sock = socket.socket(source_info[0], source_info[1]) # set options sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except AttributeError: pass sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) # multicast if target_ip.is_multicast: if source_info[0] == socket.AF_INET6: sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 2) addr = cast(AddressTupleV6Type, source_info[4]) if addr[3]: mreq = target_ip.packed + addr[3].to_bytes(4, sys.byteorder) sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq) sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, addr[3]) else: _LOGGER.debug("Skipping setting multicast interface") else: sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, source_ip.packed) sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) sock.setsockopt( socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, target_ip.packed + source_ip.packed, ) return sock, source_info[4], target_info[4] async_upnp_client-0.44.0/async_upnp_client/ssdp_listener.py000066400000000000000000000550011477256211100242700ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.ssdp_listener module.""" import asyncio import logging import re from asyncio.events import AbstractEventLoop from contextlib import suppress from datetime import datetime, timedelta from functools import lru_cache from ipaddress import ip_address from typing import ( TYPE_CHECKING, Any, Callable, Coroutine, Dict, KeysView, Mapping, Optional, Tuple, ) from urllib.parse import urlparse from async_upnp_client.advertisement import SsdpAdvertisementListener from async_upnp_client.const import ( AddressTupleVXType, DeviceOrServiceType, NotificationSubType, NotificationType, SearchTarget, SsdpSource, UniqueDeviceName, ) from async_upnp_client.search import SsdpSearchListener from async_upnp_client.ssdp import ( SSDP_MX, SSDP_ST_ALL, determine_source_target, udn_from_usn, ) from async_upnp_client.utils import CaseInsensitiveDict _SENTINEL = object() _LOGGER = logging.getLogger(__name__) CACHE_CONTROL_RE = re.compile(r"max-age\s*=\s*(\d+)", re.IGNORECASE) DEFAULT_MAX_AGE = timedelta(seconds=900) IGNORED_HEADERS = { "date", "cache-control", "server", "host", "location", # Location-header is handled differently! } @lru_cache(maxsize=128) def is_valid_location(location: str) -> bool: """Validate if this location is usable.""" return location.startswith("http") and not ( "://127.0.0.1" in location or "://[::1]" in location or "://169.254" in location ) def valid_search_headers(headers: CaseInsensitiveDict) -> bool: """Validate if this search is usable.""" return headers.lower_values_true(("_udn", "st")) and is_valid_location( headers.get_lower("location", "") ) def valid_advertisement_headers(headers: CaseInsensitiveDict) -> bool: """Validate if this advertisement is usable for connecting to a device.""" return headers.lower_values_true(("_udn", "nt", "nts")) and is_valid_location( headers.get_lower("location", "") ) def valid_byebye_headers(headers: CaseInsensitiveDict) -> bool: """Validate if this advertisement has required headers for byebye.""" return headers.lower_values_true(("_udn", "nt", "nts")) @lru_cache(maxsize=128) def extract_uncache_after(cache_control: str) -> timedelta: """Get uncache after from cache control header.""" if match := CACHE_CONTROL_RE.search(cache_control): max_age = int(match[1]) return timedelta(seconds=max_age) return DEFAULT_MAX_AGE def extract_valid_to(headers: CaseInsensitiveDict) -> datetime: """Extract/create valid to.""" uncache_after = extract_uncache_after(headers.get_lower("cache-control", "")) timestamp: datetime = headers.get_lower("_timestamp") return timestamp + uncache_after class SsdpDevice: """ SSDP Device. Holds all known information about the device. """ # pylint: disable=too-many-instance-attributes def __init__(self, udn: str, valid_to: datetime): """Initialize.""" self.udn = udn self.valid_to: datetime = valid_to self._locations: Dict[str, datetime] = {} self.last_seen: Optional[datetime] = None self.search_headers: dict[DeviceOrServiceType, CaseInsensitiveDict] = {} self.advertisement_headers: dict[DeviceOrServiceType, CaseInsensitiveDict] = {} self.userdata: Any = None def add_location(self, location: str, valid_to: datetime) -> None: """Add a (new) location the device can be reached at.""" self._locations[location] = valid_to @property def location(self) -> Optional[str]: """ Get a location of the device. Kept for compatibility, use method `locations`. """ # Sort such that the same location will be given each time. for location in sorted(self.locations): return location return None @property def locations(self) -> KeysView[str]: """Get all know locations of the device.""" return self._locations.keys() def purge_locations(self, now: Optional[datetime] = None) -> None: """Purge locations which are no longer valid/timed out.""" if not now: now = datetime.now() to_remove = [ location for location, valid_to in self._locations.items() if now > valid_to ] for location in to_remove: del self._locations[location] def combined_headers( self, device_or_service_type: DeviceOrServiceType, ) -> CaseInsensitiveDict: """Get headers from search and advertisement for a given device- or service type. If there are both search and advertisement headers, the search headers are combined with the advertisement headers and a new CaseInsensitiveDict is returned. If there are only search headers, the search headers are returned. If there are only advertisement headers, the advertisement headers are returned. If there are no headers, an empty CaseInsensitiveDict is returned. Callers should be aware that the returned CaseInsensitiveDict may be a view into the internal data structures of this class. If the caller modifies the returned CaseInsensitiveDict, the internal data structures will be modified as well. """ search_headers = self.search_headers.get(device_or_service_type, _SENTINEL) advertisement_headers = self.advertisement_headers.get( device_or_service_type, _SENTINEL ) if search_headers is not _SENTINEL and advertisement_headers is not _SENTINEL: if TYPE_CHECKING: assert isinstance(search_headers, CaseInsensitiveDict) assert isinstance(advertisement_headers, CaseInsensitiveDict) header_dict = search_headers.combine(advertisement_headers) header_dict.del_lower("_source") return header_dict if search_headers is not _SENTINEL: if TYPE_CHECKING: assert isinstance(search_headers, CaseInsensitiveDict) return search_headers if advertisement_headers is not _SENTINEL: if TYPE_CHECKING: assert isinstance(advertisement_headers, CaseInsensitiveDict) return advertisement_headers return CaseInsensitiveDict() @property def all_combined_headers(self) -> Mapping[DeviceOrServiceType, CaseInsensitiveDict]: """Get all headers from search and advertisement for all known device- and service types.""" dsts = set(self.advertisement_headers).union(set(self.search_headers)) return {dst: self.combined_headers(dst) for dst in dsts} def __repr__(self) -> str: """Return the representation.""" return f"<{type(self).__name__}({self.udn})>" def same_headers_differ( current_headers: CaseInsensitiveDict, new_headers: CaseInsensitiveDict ) -> bool: """Compare headers present in both to see if anything interesting has changed.""" current_headers_dict = current_headers.as_dict() new_headers_dict = new_headers.as_dict() new_headers_case_map = new_headers.case_map() current_headers_case_map = current_headers.case_map() for lower_header, current_header in current_headers_case_map.items(): if ( lower_header != "" and lower_header[0] == "_" ) or lower_header in IGNORED_HEADERS: continue new_header = new_headers_case_map.get(lower_header, _SENTINEL) if new_header is not _SENTINEL: current_value = current_headers_dict[current_header] new_value = new_headers_dict[new_header] # type: ignore[index] if current_value != new_value: _LOGGER.debug( "Header %s changed from %s to %s", current_header, current_value, new_value, ) return True return False def headers_differ_from_existing_advertisement( ssdp_device: SsdpDevice, dst: DeviceOrServiceType, headers: CaseInsensitiveDict ) -> bool: """Compare against existing advertisement headers to see if anything interesting has changed.""" headers_old = ssdp_device.advertisement_headers.get(dst, _SENTINEL) if headers_old is _SENTINEL: return False if TYPE_CHECKING: assert isinstance(headers_old, CaseInsensitiveDict) return same_headers_differ(headers_old, headers) def headers_differ_from_existing_search( ssdp_device: SsdpDevice, dst: DeviceOrServiceType, headers: CaseInsensitiveDict ) -> bool: """Compare against existing search headers to see if anything interesting has changed.""" headers_old = ssdp_device.search_headers.get(dst, _SENTINEL) if headers_old is _SENTINEL: return False if TYPE_CHECKING: assert isinstance(headers_old, CaseInsensitiveDict) return same_headers_differ(headers_old, headers) def ip_version_from_location(location: str) -> Optional[int]: """Get the ip version for a location.""" with suppress(ValueError): hostname = urlparse(location).hostname if not hostname: return None return ip_address(hostname).version return None def location_changed(ssdp_device: SsdpDevice, headers: CaseInsensitiveDict) -> bool: """Test if location changed for device.""" new_location = headers.get_lower("location", "") if not new_location: return False # Device did not have any location, must be new. locations = ssdp_device.locations if not locations: return True if new_location in locations: return False # Ensure the new location is parsable. new_ip_version = ip_version_from_location(new_location) if new_ip_version is None: return False # We already established the location # was not seen before. If we have any location # saved that is the same ip version, we # consider the location changed return any( ip_version_from_location(location) == new_ip_version for location in locations ) class SsdpDeviceTracker: """ Device tracker. Tracks `SsdpDevices` seen by the `SsdpListener`. Can be shared between `SsdpListeners`. """ def __init__(self) -> None: """Initialize.""" self.devices: dict[UniqueDeviceName, SsdpDevice] = {} self.next_valid_to: Optional[datetime] = None def see_search( self, headers: CaseInsensitiveDict ) -> Tuple[ bool, Optional[SsdpDevice], Optional[DeviceOrServiceType], Optional[SsdpSource] ]: """See a device through a search.""" if not valid_search_headers(headers): _LOGGER.debug("Received invalid search headers: %s", headers) return False, None, None, None udn = headers.get_lower("_udn") is_new_device = udn not in self.devices ssdp_device, new_location = self._see_device(headers) if not ssdp_device: return False, None, None, None search_target: SearchTarget = headers.get_lower("st") is_new_service = ( search_target not in ssdp_device.advertisement_headers and search_target not in ssdp_device.search_headers ) if is_new_service: _LOGGER.debug("See new service: %s, type: %s", ssdp_device, search_target) changed = ( is_new_device or is_new_service or new_location or headers_differ_from_existing_search(ssdp_device, search_target, headers) ) ssdp_source = SsdpSource.SEARCH_CHANGED if changed else SsdpSource.SEARCH_ALIVE # Update stored headers. search_headers = ssdp_device.search_headers if search_target in ssdp_device.search_headers: search_headers[search_target].replace(headers) else: search_headers[search_target] = headers return True, ssdp_device, search_target, ssdp_source def see_advertisement( self, headers: CaseInsensitiveDict ) -> Tuple[bool, Optional[SsdpDevice], Optional[DeviceOrServiceType]]: """See a device through an advertisement.""" if not valid_advertisement_headers(headers): _LOGGER.debug("Received invalid advertisement headers: %s", headers) return False, None, None udn = headers.get_lower("_udn") is_new_device = udn not in self.devices ssdp_device, new_location = self._see_device(headers) if not ssdp_device: return False, None, None notification_type: NotificationType = headers.get_lower("nt") is_new_service = ( notification_type not in ssdp_device.advertisement_headers and notification_type not in ssdp_device.search_headers ) if is_new_service: _LOGGER.debug( "See new service: %s, type: %s", ssdp_device, notification_type ) notification_sub_type: NotificationSubType = headers.get_lower("nts") propagate = ( notification_sub_type == NotificationSubType.SSDP_UPDATE or is_new_device or is_new_service or new_location or headers_differ_from_existing_advertisement( ssdp_device, notification_type, headers ) ) # Update stored headers. advertisement_headers = ssdp_device.advertisement_headers if notification_type in advertisement_headers: advertisement_headers[notification_type].replace(headers) else: advertisement_headers[notification_type] = headers return propagate, ssdp_device, notification_type def _see_device( self, headers: CaseInsensitiveDict ) -> Tuple[Optional[SsdpDevice], bool]: """See a device through a search or advertisement.""" # Purge any old devices. now = headers.get_lower("_timestamp") self.purge_devices(now) if not (usn := headers.get_lower("usn")) or not (udn := udn_from_usn(usn)): # Ignore broken devices. return None, False valid_to = extract_valid_to(headers) if udn not in self.devices: # Create new device. ssdp_device = SsdpDevice(udn, valid_to) _LOGGER.debug("See new device: %s", ssdp_device) self.devices[udn] = ssdp_device else: ssdp_device = self.devices[udn] ssdp_device.valid_to = valid_to # Test if new location. new_location = location_changed(ssdp_device, headers) # Update device. ssdp_device.add_location(headers.get_lower("location"), valid_to) ssdp_device.last_seen = now if not self.next_valid_to or self.next_valid_to > ssdp_device.valid_to: self.next_valid_to = ssdp_device.valid_to return ssdp_device, new_location def unsee_advertisement( self, headers: CaseInsensitiveDict ) -> Tuple[bool, Optional[SsdpDevice], Optional[DeviceOrServiceType]]: """Remove a device through an advertisement.""" if not valid_byebye_headers(headers): return False, None, None if ( not (usn := headers.get_lower("usn")) or not (udn := udn_from_usn(usn)) or not (ssdp_device := self.devices.get(udn)) ): # Ignore broken devices and devices we don't know about. return False, None, None del self.devices[udn] # Update device before propagating it notification_type: NotificationType = headers.get_lower("nt") advertisement_headers = ssdp_device.advertisement_headers if notification_type in advertisement_headers: advertisement_headers[notification_type].replace(headers) else: advertisement_headers[notification_type] = CaseInsensitiveDict(headers) propagate = True # Always true, if this is the 2nd unsee then device is already deleted. return propagate, ssdp_device, notification_type def get_device(self, headers: CaseInsensitiveDict) -> Optional[SsdpDevice]: """Get a device from headers.""" if not (usn := headers.get_lower("usn")) or not (udn := udn_from_usn(usn)): return None return self.devices.get(udn) def purge_devices(self, override_now: Optional[datetime] = None) -> None: """Purge any devices for which the CACHE-CONTROL header is timed out.""" now = override_now or datetime.now() if self.next_valid_to and self.next_valid_to > now: return self.next_valid_to = None to_remove = [] for usn, device in self.devices.items(): if now > device.valid_to: to_remove.append(usn) elif not self.next_valid_to or device.valid_to < self.next_valid_to: self.next_valid_to = device.valid_to device.purge_locations(now) for usn in to_remove: _LOGGER.debug("Purging device, USN: %s", usn) del self.devices[usn] class SsdpListener: """SSDP Search and Advertisement listener.""" # pylint: disable=too-many-instance-attributes def __init__( self, async_callback: Optional[ Callable[ [SsdpDevice, DeviceOrServiceType, SsdpSource], Coroutine[Any, Any, None] ] ] = None, callback: Optional[ Callable[[SsdpDevice, DeviceOrServiceType, SsdpSource], None] ] = None, source: Optional[AddressTupleVXType] = None, target: Optional[AddressTupleVXType] = None, loop: Optional[AbstractEventLoop] = None, search_timeout: int = SSDP_MX, search_target: str = SSDP_ST_ALL, device_tracker: Optional[SsdpDeviceTracker] = None, ) -> None: """Initialize.""" # pylint: disable=too-many-arguments,too-many-positional-arguments assert callback or async_callback, "Provide at least one callback" self.async_callback = async_callback self.callback = callback self.source, self.target = determine_source_target(source, target) self.loop = loop or asyncio.get_event_loop() self.search_timeout = search_timeout self.search_target = search_target self._device_tracker = device_tracker or SsdpDeviceTracker() self._advertisement_listener: Optional[SsdpAdvertisementListener] = None self._search_listener: Optional[SsdpSearchListener] = None async def async_start(self) -> None: """Start search listener/advertisement listener.""" self._advertisement_listener = SsdpAdvertisementListener( on_alive=self._on_alive, on_update=self._on_update, on_byebye=self._on_byebye, source=self.source, target=self.target, loop=self.loop, ) await self._advertisement_listener.async_start() self._search_listener = SsdpSearchListener( callback=self._on_search, loop=self.loop, source=self.source, target=self.target, timeout=self.search_timeout, search_target=self.search_target, ) await self._search_listener.async_start() async def async_stop(self) -> None: """Stop scanner/listener.""" if self._advertisement_listener: await self._advertisement_listener.async_stop() if self._search_listener: self._search_listener.async_stop() async def async_search( self, override_target: Optional[AddressTupleVXType] = None ) -> None: """Send a SSDP Search packet.""" assert self._search_listener is not None, "Call async_start() first" self._search_listener.async_search(override_target) def _on_search(self, headers: CaseInsensitiveDict) -> None: """Search callback.""" ( propagate, ssdp_device, device_or_service_type, ssdp_source, ) = self._device_tracker.see_search(headers) if propagate and ssdp_device and device_or_service_type: assert ssdp_source is not None if self.async_callback: coro = self.async_callback( ssdp_device, device_or_service_type, ssdp_source ) self.loop.create_task(coro) if self.callback: self.callback(ssdp_device, device_or_service_type, ssdp_source) def _on_alive(self, headers: CaseInsensitiveDict) -> None: """On alive.""" ( propagate, ssdp_device, device_or_service_type, ) = self._device_tracker.see_advertisement(headers) if propagate and ssdp_device and device_or_service_type: if self.async_callback: coro = self.async_callback( ssdp_device, device_or_service_type, SsdpSource.ADVERTISEMENT_ALIVE ) self.loop.create_task(coro) if self.callback: self.callback( ssdp_device, device_or_service_type, SsdpSource.ADVERTISEMENT_ALIVE ) def _on_byebye(self, headers: CaseInsensitiveDict) -> None: """On byebye.""" ( propagate, ssdp_device, device_or_service_type, ) = self._device_tracker.unsee_advertisement(headers) if propagate and ssdp_device and device_or_service_type: if self.async_callback: coro = self.async_callback( ssdp_device, device_or_service_type, SsdpSource.ADVERTISEMENT_BYEBYE ) self.loop.create_task(coro) if self.callback: self.callback( ssdp_device, device_or_service_type, SsdpSource.ADVERTISEMENT_BYEBYE ) def _on_update(self, headers: CaseInsensitiveDict) -> None: """On update.""" ( propagate, ssdp_device, device_or_service_type, ) = self._device_tracker.see_advertisement(headers) if propagate and ssdp_device and device_or_service_type: if self.async_callback: coro = self.async_callback( ssdp_device, device_or_service_type, SsdpSource.ADVERTISEMENT_UPDATE ) self.loop.create_task(coro) if self.callback: self.callback( ssdp_device, device_or_service_type, SsdpSource.ADVERTISEMENT_UPDATE ) @property def devices(self) -> Mapping[str, SsdpDevice]: """Get the known devices.""" return self._device_tracker.devices async_upnp_client-0.44.0/async_upnp_client/utils.py000066400000000000000000000304761477256211100225630ustar00rootroot00000000000000# -*- coding: utf-8 -*- """async_upnp_client.utils module.""" import asyncio import re import socket from collections import defaultdict from collections.abc import Mapping as abcMapping from collections.abc import MutableMapping as abcMutableMapping from datetime import datetime, timedelta, timezone from socket import AddressFamily # pylint: disable=no-name-in-module from typing import Any, Callable, Dict, Generator, Optional, Tuple from urllib.parse import urljoin, urlsplit import defusedxml.ElementTree as DET from voluptuous import Invalid EXTERNAL_IP = "1.1.1.1" EXTERNAL_PORT = 80 UTC = timezone(timedelta(hours=0)) _UNCOMPILED_MATCHERS: Dict[str, Callable] = { # date r"\d{4}-\d{2}-\d{2}$": lambda value: datetime.strptime(value, "%Y-%m-%d").date(), r"\d{2}:\d{2}:\d{2}$": lambda value: datetime.strptime(value, "%H:%M:%S").time(), # datetime r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$": lambda value: datetime.strptime( value, "%Y-%m-%dT%H:%M:%S" ), r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$": lambda value: datetime.strptime( value, "%Y-%m-%d %H:%M:%S" ), # time.tz r"\d{2}:\d{2}:\d{2}[+-]\d{4}$": lambda value: datetime.strptime( value, "%H:%M:%S%z" ).timetz(), r"\d{2}:\d{2}:\d{2} [+-]\d{4}$": lambda value: datetime.strptime( value, "%H:%M:%S %z" ).timetz(), # datetime.tz r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}z$": lambda value: datetime.strptime( value, "%Y-%m-%dT%H:%M:%Sz" ).replace(tzinfo=UTC), r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$": lambda value: datetime.strptime( value, "%Y-%m-%dT%H:%M:%Sz" ).replace(tzinfo=UTC), r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$": lambda value: datetime.strptime( value, "%Y-%m-%dT%H:%M:%S%z" ), r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} [+-]\d{4}$": lambda value: datetime.strptime( value, "%Y-%m-%dT%H:%M:%S %z" ), } COMPILED_MATCHERS: Dict[re.Pattern, Callable] = { re.compile(matcher): parser for matcher, parser in _UNCOMPILED_MATCHERS.items() } TIME_RE = re.compile(r"(?P[-+])?(?P\d+):(?P\d+):(?P\d+)\.?(?P\d+)?") class lowerstr(str): # pylint: disable=invalid-name """A prelowered string.""" class CaseInsensitiveDict(abcMutableMapping): """Case insensitive dict.""" __slots__ = ("_data", "_case_map") def __init__(self, data: Optional[abcMapping] = None, **kwargs: Any) -> None: """Initialize.""" self._data: Dict[Any, Any] = {**(data or {}), **kwargs} self._case_map: Dict[str, Any] = { ( k if type(k) is lowerstr # pylint: disable=unidiomatic-typecheck else k.lower() ): k for k in self._data } def copy(self) -> "CaseInsensitiveDict": """Copy a CaseInsensitiveDict. Returns a copy of CaseInsensitiveDict. """ # pylint: disable=protected-access _copy = CaseInsensitiveDict.__new__(CaseInsensitiveDict) _copy._data = self._data.copy() _copy._case_map = self._case_map.copy() return _copy def combine(self, other: "CaseInsensitiveDict") -> "CaseInsensitiveDict": """Combine a CaseInsensitiveDict with another CaseInsensitiveDict. Returns a brand new CaseInsensitiveDict that is the combination of the two CaseInsensitiveDicts. """ # pylint: disable=protected-access _combined = CaseInsensitiveDict.__new__(CaseInsensitiveDict) _combined._data = {**self._data, **other._data} _combined._case_map = {**self._case_map, **other._case_map} return _combined def combine_lower_dict( self, lower_dict: Dict[lowerstr, Any] ) -> "CaseInsensitiveDict": """Combine a CaseInsensitiveDict with a dict where all the keys are lowerstr. Returns a brand new CaseInsensitiveDict that is the combination of the CaseInsensitiveDict and dict where all the keys are lowerstr. """ # pylint: disable=protected-access _combined = CaseInsensitiveDict.__new__(CaseInsensitiveDict) _combined._data = {**self._data, **lower_dict} _combined._case_map = {**self._case_map, **{k: k for k in lower_dict}} return _combined def case_map(self) -> Dict[str, str]: """Get the case map.""" return self._case_map def as_dict(self) -> Dict[str, Any]: """Return the underlying dict without iterating.""" return self._data def as_lower_dict(self) -> Dict[str, Any]: """Return the underlying dict in lowercase.""" return {k.lower(): v for k, v in self._data.items()} def get_lower(self, lower_key: str, default: Any = None) -> Any: """Get a lower case key.""" return self._data.get(self._case_map.get(lower_key), default) def lower_values_true(self, lower_keys: Tuple[str, ...]) -> bool: """Check if all lower case keys are present and true values.""" for lower_key in lower_keys: if not self._data.get(self._case_map.get(lower_key)): return False return True def replace(self, new_data: abcMapping) -> None: """Replace the underlying dict without making a copy if possible.""" if isinstance(new_data, CaseInsensitiveDict): self._data = new_data.as_dict() self._case_map = new_data.case_map() else: self._data = {**new_data} self._case_map = { ( k if type(k) is lowerstr # pylint: disable=unidiomatic-typecheck else k.lower() ): k for k in self._data } def del_lower(self, lower_key: str) -> None: """Delete a lower case key.""" del self._data[self._case_map[lower_key]] del self._case_map[lower_key] def __setitem__(self, key: str, value: Any) -> None: """Set item.""" lower_key = key.lower() if self._case_map.get(lower_key, key) != key: # Case changed del self._data[self._case_map[lower_key]] self._data[key] = value self._case_map[lower_key] = key def __getitem__(self, key: str) -> Any: """Get item.""" return self._data[self._case_map[key.lower()]] def __delitem__(self, key: str) -> None: """Del item.""" lower_key = key.lower() del self._data[self._case_map[lower_key]] del self._case_map[lower_key] def __len__(self) -> int: """Get length.""" return len(self._data) def __iter__(self) -> Generator[str, None, None]: """Get iterator.""" return (key for key in self._data.keys()) def __repr__(self) -> str: """Repr.""" return repr(self._data) def __str__(self) -> str: """Str.""" return str(self._data) def __eq__(self, other: Any) -> bool: """Compare for equality.""" if isinstance(other, CaseInsensitiveDict): return self.as_lower_dict() == other.as_lower_dict() if isinstance(other, abcMapping): return self.as_lower_dict() == { key.lower(): value for key, value in other.items() } return NotImplemented def __hash__(self) -> int: """Get hash.""" return hash(tuple(sorted(self._data.items()))) def time_to_str(time: timedelta) -> str: """Convert timedelta to str/units.""" total_seconds = abs(time.total_seconds()) target = { "sign": "-" if time.total_seconds() < 0 else "", "hours": int(total_seconds // 3600), "minutes": int(total_seconds % 3600 // 60), "seconds": int(total_seconds % 60), } return "{sign}{hours}:{minutes}:{seconds}".format(**target) def str_to_time(string: str) -> Optional[timedelta]: """Convert a string to timedelta.""" match = TIME_RE.match(string) if not match: return None sign = -1 if match.group("sign") == "-" else 1 hours = int(match.group("h")) minutes = int(match.group("m")) seconds = int(match.group("s")) if match.group("ms"): msec = int(match.group("ms")) else: msec = 0 return sign * timedelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=msec ) def absolute_url(device_url: str, url: str) -> str: """ Convert a relative URL to an absolute URL pointing at device. If url is already an absolute url (i.e., starts with http:/https:), then the url itself is returned. """ if url.startswith("http:") or url.startswith("https:"): return url return urljoin(device_url, url) def require_tzinfo(value: Any) -> Any: """Require tzinfo.""" if value.tzinfo is None: raise Invalid("Requires tzinfo") return value def parse_date_time(value: str) -> Any: """Parse a date/time/date_time value.""" # fix up timezone part if value[-6] in ["+", "-"] and value[-3] == ":": value = value[:-3] + value[-2:] for pattern, parser in COMPILED_MATCHERS.items(): if pattern.match(value): return parser(value) raise ValueError("Unknown date/time: " + value) def _target_url_to_addr(target_url: Optional[str]) -> Tuple[str, int]: """Resolve target_url into an address usable for get_local_ip.""" if target_url: if "//" not in target_url: # Make sure urllib can work with target_url to get the host target_url = "//" + target_url target_url_split = urlsplit(target_url) target_host = target_url_split.hostname or EXTERNAL_IP target_port = target_url_split.port or EXTERNAL_PORT else: target_host = EXTERNAL_IP target_port = EXTERNAL_PORT return target_host, target_port def get_local_ip(target_url: Optional[str] = None) -> str: """Try to get the local IP of this machine, used to talk to target_url. Only IPv4 addresses are supported. """ target_addr = _target_url_to_addr(target_url) try: temp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) temp_sock.connect(target_addr) local_ip: str = temp_sock.getsockname()[0] return local_ip finally: temp_sock.close() async def async_get_local_ip( target_url: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None ) -> Tuple[AddressFamily, str]: """Try to get the local IP of this machine, used to talk to target_url. IPv4 and IPv6 are supported. For IPv6 link-local addresses the local IP may include the scope ID (zone index). """ target_addr = _target_url_to_addr(target_url) loop = loop or asyncio.get_event_loop() # Create a UDP connection to the target. This won't cause any network # traffic but will assign a local IP to the socket. transport, _ = await loop.create_datagram_endpoint( asyncio.protocols.DatagramProtocol, remote_addr=target_addr ) try: sock = transport.get_extra_info("socket") sockname = sock.getsockname() host, _ = socket.getnameinfo( sockname, socket.NI_NUMERICHOST | socket.NI_NUMERICSERV ) return sock.family, host finally: transport.close() # Adapted from http://stackoverflow.com/a/10077069 # to follow the XML to JSON spec # https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html def etree_to_dict(tree: DET) -> Dict[str, Optional[Dict[str, Any]]]: """Convert an ETree object to a dict.""" # strip namespace tag_name = tree.tag[tree.tag.find("}") + 1 :] tree_dict: Dict[str, Optional[Dict[str, Any]]] = { tag_name: {} if tree.attrib else None } children = list(tree) if children: child_dict: Dict[str, list] = defaultdict(list) for child in map(etree_to_dict, children): for k, val in child.items(): child_dict[k].append(val) tree_dict = { tag_name: {k: v[0] if len(v) == 1 else v for k, v in child_dict.items()} } dict_meta = tree_dict[tag_name] if tree.attrib: assert dict_meta is not None dict_meta.update(("@" + k, v) for k, v in tree.attrib.items()) if tree.text: text = tree.text.strip() if children or tree.attrib: if text: assert dict_meta is not None dict_meta["#text"] = text else: tree_dict[tag_name] = text return tree_dict async_upnp_client-0.44.0/changes/000077500000000000000000000000001477256211100167325ustar00rootroot00000000000000async_upnp_client-0.44.0/changes/.gitignore000066400000000000000000000000141477256211100207150ustar00rootroot00000000000000!.gitignore async_upnp_client-0.44.0/codecov.yml000066400000000000000000000000001477256211100174550ustar00rootroot00000000000000async_upnp_client-0.44.0/contrib/000077500000000000000000000000001477256211100167625ustar00rootroot00000000000000async_upnp_client-0.44.0/contrib/dummy_router.py000066400000000000000000000601131477256211100220700ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Dummy router supporting IGD.""" # Instructions: # - Change `SOURCE``. When using IPv6, be sure to set the scope_id, the last value in the tuple. # - Run this module. # - Run upnp-client (change IP to your own IP): # upnp-client call-action 'http://0.0.0.0:8000/device.xml' \ # WANCIC/GetTotalPacketsReceived import asyncio import logging import xml.etree.ElementTree as ET from time import time from typing import Dict, Mapping, Sequence, Tuple, Type, cast from async_upnp_client.client import UpnpRequester, UpnpStateVariable from async_upnp_client.const import ( STATE_VARIABLE_TYPE_MAPPING, DeviceInfo, EventableStateVariableTypeInfo, ServiceInfo, StateVariableTypeInfo, ) from async_upnp_client.profiles.igd import Pinhole, PortMappingEntry from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService, callable_action logging.basicConfig(level=logging.DEBUG) LOGGER = logging.getLogger("dummy_router") LOGGER_SSDP_TRAFFIC = logging.getLogger("async_upnp_client.traffic") LOGGER_SSDP_TRAFFIC.setLevel(logging.WARNING) SOURCE = ("192.168.178.54", 0) # Your IP here! # SOURCE = ("fe80::215:5dff:fe3e:6d23", 0, 0, 6) # Your IP here! HTTP_PORT = 8000 class WANIPv6FirewallControlService(UpnpServerService): """WANIPv6FirewallControl service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:WANIPv6FirewallControl1", service_type="urn:schemas-upnp-org:service:WANIPv6FirewallControl:1", control_url="/upnp/control/WANIPv6FirewallControl1", event_sub_url="/upnp/event/WANIPv6FirewallControl1", scpd_url="/WANIPv6FirewallControl_1.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "FirewallEnabled": EventableStateVariableTypeInfo( data_type="boolean", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["boolean"], default_value="1", allowed_value_range={}, allowed_values=None, max_rate=None, xml=ET.Element("server_stateVariable"), ), "InboundPinholeAllowed": EventableStateVariableTypeInfo( data_type="boolean", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["boolean"], default_value="1", allowed_value_range={}, allowed_values=None, max_rate=None, xml=ET.Element("server_stateVariable"), ), "A_ARG_TYPE_IPv6Address": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "A_ARG_TYPE_Port": StateVariableTypeInfo( data_type="ui2", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui2"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "A_ARG_TYPE_Protocol": StateVariableTypeInfo( data_type="ui2", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui2"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "A_ARG_TYPE_LeaseTime": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value=None, allowed_value_range={ "min": "1", "max": "86400", }, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "A_ARG_TYPE_UniqueID": StateVariableTypeInfo( data_type="ui2", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui2"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), } def __init__(self, *args, **kwargs) -> None: """Initialize.""" super().__init__(*args, **kwargs) self._pinholes: Dict[int, Pinhole] = {} self._next_pinhole_id = 0 @callable_action( name="GetFirewallStatus", in_args={}, out_args={ "FirewallEnabled": "FirewallEnabled", "InboundPinholeAllowed": "InboundPinholeAllowed", }, ) async def get_firewall_status(self) -> Dict[str, UpnpStateVariable]: """Get firewall status.""" return { "FirewallEnabled": self.state_variable("FirewallEnabled"), "InboundPinholeAllowed": self.state_variable("InboundPinholeAllowed"), } @callable_action( name="AddPinhole", in_args={ "RemoteHost": "A_ARG_TYPE_IPv6Address", "RemotePort": "A_ARG_TYPE_Port", "InternalClient": "A_ARG_TYPE_IPv6Address", "InternalPort": "A_ARG_TYPE_Port", "Protocol": "A_ARG_TYPE_Protocol", "LeaseTime": "A_ARG_TYPE_LeaseTime", }, out_args={ "UniqueID": "A_ARG_TYPE_UniqueID", }, ) async def add_pinhole(self, RemoteHost: str, RemotePort: int, InternalClient: str, InternalPort: int, Protocol: int, LeaseTime: int) -> Dict[str, UpnpStateVariable]: """Add pinhole.""" # pylint: disable=invalid-name pinhole_id = self._next_pinhole_id self._next_pinhole_id += 1 pinhole = Pinhole( remote_host=RemoteHost, remote_port=RemotePort, internal_client=InternalClient, internal_port=InternalPort, protocol=Protocol, lease_time=LeaseTime, ) self._pinholes[pinhole_id] = pinhole return { "UniqueID": pinhole_id, } @callable_action( name="UpdatePinhole", in_args={ "UniqueID": "A_ARG_TYPE_UniqueID", "LeaseTime": "A_ARG_TYPE_LeaseTime", }, out_args={}, ) async def update_pinhole(self, UniqueID: int, LeaseTime: int) -> Dict[str, UpnpStateVariable]: """Update pinhole.""" # pylint: disable=invalid-name self._pinholes[UniqueID].lease_time = LeaseTime return {} @callable_action( name="DeletePinhole", in_args={ "UniqueID": "A_ARG_TYPE_UniqueID", }, out_args={}, ) async def delete_pinhole(self, UniqueID: int) -> Dict[str, UpnpStateVariable]: """Delete pinhole.""" # pylint: disable=invalid-name del self._pinholes[UniqueID] return {} class WANIPConnectionService(UpnpServerService): """WANIPConnection service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:WANIPConnection1", service_type="urn:schemas-upnp-org:service:WANIPConnection:1", control_url="/upnp/control/WANIPConnection1", event_sub_url="/upnp/event/WANIPConnection1", scpd_url="/WANIPConnection_1.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "ExternalIPAddress": EventableStateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="1.2.3.0", allowed_value_range={}, allowed_values=None, max_rate=None, xml=ET.Element("server_stateVariable"), ), "ConnectionStatus": EventableStateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="Unconfigured", allowed_value_range={}, allowed_values=[ "Unconfigured", "Authenticating", "Connecting", "Connected", "PendingDisconnect", "Disconnecting", "Disconnected", ], max_rate=None, xml=ET.Element("server_stateVariable"), ), "LastConnectionError": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="ERROR_NONE", allowed_value_range={}, allowed_values=[ "ERROR_NONE", ], xml=ET.Element("server_stateVariable"), ), "Uptime": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value="0", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "RemoteHost": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "ExternalPort": StateVariableTypeInfo( data_type="ui2", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui2"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "PortMappingProtocol": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value=None, allowed_value_range={}, allowed_values=["TCP", "UDP"], xml=ET.Element("server_stateVariable"), ), "InternalPort": StateVariableTypeInfo( data_type="ui2", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui2"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "InternalClient": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "PortMappingEnabled": StateVariableTypeInfo( data_type="boolean", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["boolean"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "PortMappingDescription": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "PortMappingLeaseDuration": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "PortMappingNumberOfEntries": EventableStateVariableTypeInfo( data_type="ui2", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui2"], default_value="0", allowed_value_range={ "min": "0", "max": "65535", "step": "1" }, allowed_values=None, max_rate=0, xml=ET.Element("server_stateVariable"), ) } def __init__(self, *args, **kwargs) -> None: """Initialize.""" super().__init__(*args, **kwargs) self._port_mappings: Dict[Tuple[str, int, str, str], PortMappingEntry] = {} @callable_action( name="GetStatusInfo", in_args={}, out_args={ "NewConnectionStatus": "ConnectionStatus", "NewLastConnectionError": "LastConnectionError", "NewUptime": "Uptime", }, ) async def get_status_info(self) -> Dict[str, UpnpStateVariable]: """Get status info.""" # from async_upnp_client.exceptions import UpnpActionError, UpnpActionErrorCode # raise UpnpActionError( # error_code=UpnpActionErrorCode.INVALID_ACTION, error_desc="Invalid action" # ) return { "NewConnectionStatus": self.state_variable("ConnectionStatus"), "NewLastConnectionError": self.state_variable("LastConnectionError"), "NewUptime": self.state_variable("Uptime"), } @callable_action( name="GetExternalIPAddress", in_args={}, out_args={ "NewExternalIPAddress": "ExternalIPAddress", }, ) async def get_external_ip_address(self) -> Dict[str, UpnpStateVariable]: """Get external IP address.""" # from async_upnp_client.exceptions import UpnpActionError, UpnpActionErrorCode # raise UpnpActionError( # error_code=UpnpActionErrorCode.INVALID_ACTION, error_desc="Invalid action" # ) return { "NewExternalIPAddress": self.state_variable("ExternalIPAddress"), } @callable_action( name="AddPortMapping", in_args={ "NewRemoteHost": "RemoteHost", "NewExternalPort": "ExternalPort", "NewProtocol": "PortMappingProtocol", "NewInternalPort": "InternalPort", "NewInternalClient": "InternalClient", "NewEnabled": "PortMappingEnabled", "NewPortMappingDescription": "PortMappingDescription", "NewLeaseDuration": "PortMappingLeaseDuration", }, out_args={}, ) async def add_port_mapping(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int, NewInternalClient: str, NewEnabled: bool, NewPortMappingDescription: str, NewLeaseDuration: int) -> Dict[str, UpnpStateVariable]: """Add port mapping.""" # pylint: disable=invalid-name key = (NewRemoteHost, NewExternalPort, NewProtocol) existing_port_mapping = key in self._port_mappings self._port_mappings[key] = PortMappingEntry( remote_host=NewRemoteHost, external_port=NewExternalPort, protocol=NewProtocol, internal_client=NewInternalClient, internal_port=NewInternalPort, enabled=NewEnabled, description=NewPortMappingDescription, lease_duration=NewLeaseDuration, ) if not existing_port_mapping: self.state_variable("PortMappingNumberOfEntries").value += 1 return {} @callable_action( name="DeletePortMapping", in_args={ "NewRemoteHost": "RemoteHost", "NewExternalPort": "ExternalPort", "NewProtocol": "PortMappingProtocol", }, out_args={}, ) async def delete_port_mapping(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> Dict[str, UpnpStateVariable]: """Delete an existing port mapping entry.""" # pylint: disable=invalid-name key = (NewRemoteHost, NewExternalPort, NewProtocol) del self._port_mappings[key] self.state_variable("PortMappingNumberOfEntries").value -= 1 return {} class WanConnectionDevice(UpnpServerDevice): """WAN Connection device.""" DEVICE_DEFINITION = DeviceInfo( device_type="urn:schemas-upnp-org:device:WANConnectionDevice:1", friendly_name="Dummy Router WAN Connection Device", manufacturer="Steven", manufacturer_url=None, model_name="DummyRouter v1", model_url=None, udn="uuid:51e00c19-c8f3-4b28-9ef1-7f562f204c82", upc=None, model_description="Dummy Router IGD", model_number="v0.0.1", serial_number="0000001", presentation_url=None, url="/device.xml", icons=[], xml=ET.Element("server_device"), ) EMBEDDED_DEVICES: Sequence[Type[UpnpServerDevice]] = [] SERVICES = [WANIPConnectionService, WANIPv6FirewallControlService] def __init__(self, requester: UpnpRequester, base_uri: str, boot_id: int, config_id: int) -> None: """Initialize.""" super().__init__( requester=requester, base_uri=base_uri, boot_id=boot_id, config_id=config_id, ) class WANCommonInterfaceConfigService(UpnpServerService): """WANCommonInterfaceConfig service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:WANCommonInterfaceConfig1", service_type="urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", control_url="/upnp/control/WANCommonInterfaceConfig1", event_sub_url="/upnp/event/WANCommonInterfaceConfig1", scpd_url="/WANCommonInterfaceConfig_1.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "TotalBytesReceived": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value="0", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "TotalBytesSent": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value="0", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "TotalPacketsReceived": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value="0", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "TotalPacketsSent": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value="0", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), } MAX_COUNTER = 2**32 def _update_bytes(self, state_var_name: str) -> None: """Update bytes state variable.""" new_bytes = int(time() * 1000) % self.MAX_COUNTER self.state_variable(state_var_name).value = new_bytes def _update_packets(self, state_var_name: str) -> None: """Update state variable values.""" new_packets = int(time()) % self.MAX_COUNTER self.state_variable(state_var_name).value = new_packets self.state_variable(state_var_name).value = new_packets @callable_action( name="GetTotalBytesReceived", in_args={}, out_args={ "NewTotalBytesReceived": "TotalBytesReceived", }, ) async def get_total_bytes_received(self) -> Dict[str, UpnpStateVariable]: """Get total bytes received.""" self._update_bytes("TotalBytesReceived") return { "NewTotalBytesReceived": self.state_variable("TotalBytesReceived"), } @callable_action( name="GetTotalBytesSent", in_args={}, out_args={ "NewTotalBytesSent": "TotalBytesSent", }, ) async def get_total_bytes_sent(self) -> Dict[str, UpnpStateVariable]: """Get total bytes sent.""" self._update_bytes("TotalBytesSent") return { "NewTotalBytesSent": self.state_variable("TotalBytesSent"), } @callable_action( name="GetTotalPacketsReceived", in_args={}, out_args={ "NewTotalPacketsReceived": "TotalPacketsReceived", }, ) async def get_total_packets_received(self) -> Dict[str, UpnpStateVariable]: """Get total packets received.""" self._update_packets("TotalPacketsReceived") return { "NewTotalPacketsReceived": self.state_variable("TotalPacketsReceived"), } @callable_action( name="GetTotalPacketsSent", in_args={}, out_args={ "NewTotalPacketsSent": "TotalPacketsSent", }, ) async def get_total_packets_sent(self) -> Dict[str, UpnpStateVariable]: """Get total packets sent.""" self._update_packets("TotalPacketsSent") return { "NewTotalPacketsSent": self.state_variable("TotalPacketsSent"), } class WanDevice(UpnpServerDevice): """WAN device.""" DEVICE_DEFINITION = DeviceInfo( device_type="urn:schemas-upnp-org:device:WANDevice:1", friendly_name="Dummy Router WAN Device", manufacturer="Steven", manufacturer_url=None, model_name="DummyRouter v1", model_url=None, udn="uuid:51e00c19-c8f3-4b28-9ef1-7f562f204c81", upc=None, model_description="Dummy Router IGD", model_number="v0.0.1", serial_number="0000001", presentation_url=None, url="/device.xml", icons=[], xml=ET.Element("server_device"), ) EMBEDDED_DEVICES = [WanConnectionDevice] SERVICES = [WANCommonInterfaceConfigService] def __init__(self, requester: UpnpRequester, base_uri: str, boot_id: int, config_id: int) -> None: """Initialize.""" super().__init__( requester=requester, base_uri=base_uri, boot_id=boot_id, config_id=config_id, ) class Layer3ForwardingService(UpnpServerService): """Layer3Forwarding service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:Layer3Forwarding1", service_type="urn:schemas-upnp-org:service:Layer3Forwarding:1", control_url="/upnp/control/Layer3Forwarding1", event_sub_url="/upnp/event/Layer3Forwarding1", scpd_url="/Layer3Forwarding_1.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS: Mapping[str, StateVariableTypeInfo] = {} class IgdDevice(UpnpServerDevice): """IGD device.""" DEVICE_DEFINITION = DeviceInfo( device_type="urn:schemas-upnp-org:device:InternetGatewayDevice:1", friendly_name="Dummy Router", manufacturer="Steven", manufacturer_url=None, model_name="DummyRouter v1", model_url=None, udn="uuid:51e00c19-c8f3-4b28-9ef1-7f562f204c80", upc=None, model_description="Dummy Router IGD", model_number="v0.0.1", serial_number="0000001", presentation_url=None, url="/device.xml", icons=[], xml=ET.Element("server_device"), ) EMBEDDED_DEVICES = [WanDevice] SERVICES = [Layer3ForwardingService] def __init__(self, requester: UpnpRequester, base_uri: str, boot_id: int, config_id: int) -> None: """Initialize.""" super().__init__( requester=requester, base_uri=base_uri, boot_id=boot_id, config_id=config_id, ) async def async_main(server: UpnpServer) -> None: """Main.""" await server.async_start() loop_no = 0 while True: upnp_service = server._device.find_service("urn:schemas-upnp-org:service:WANIPConnection:1") wanipc_service = cast(WANIPConnectionService, upnp_service) external_ip_address_var = wanipc_service.state_variable("ExternalIPAddress") external_ip_address_var.value = f"1.2.3.{(loop_no % 255) + 1}" number_of_port_entries_var = wanipc_service.state_variable("PortMappingNumberOfEntries") number_of_port_entries_var.value = loop_no % 10 await asyncio.sleep(30) loop_no += 1 async def async_stop(server: UpnpServer) -> None: await server.async_stop() loop = asyncio.get_event_loop() loop.run_until_complete() if __name__ == "__main__": boot_id = int(time()) config_id = 1 server = UpnpServer(IgdDevice, SOURCE, http_port=HTTP_PORT, boot_id=boot_id, config_id=config_id) try: asyncio.run(async_main(server)) except KeyboardInterrupt: print(KeyboardInterrupt) asyncio.run(server.async_stop()) async_upnp_client-0.44.0/contrib/dummy_tv.py000066400000000000000000000373141477256211100212100ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Dummy TV supporting DLNA/DMR.""" # Instructions: # - Change `SOURCE``. When using IPv6, be sure to set the scope_id, the last value in the tuple. # - Run this module. # - Run upnp-client (change IP to your own IP): # upnp-client call-action 'http://0.0.0.0:8000/device.xml' \ # RC/GetVolume InstanceID=0 Channel=Master import asyncio import logging import xml.etree.ElementTree as ET from time import time from typing import Dict, Sequence, Type from async_upnp_client.client import UpnpRequester, UpnpStateVariable from async_upnp_client.const import ( STATE_VARIABLE_TYPE_MAPPING, DeviceInfo, ServiceInfo, StateVariableTypeInfo, ) from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService, callable_action logging.basicConfig(level=logging.DEBUG) LOGGER = logging.getLogger("dummy_tv") LOGGER_SSDP_TRAFFIC = logging.getLogger("async_upnp_client.traffic") LOGGER_SSDP_TRAFFIC.setLevel(logging.WARNING) SOURCE = ("172.25.113.128", 0) # Your IP here! # SOURCE = ("fe80::215:5dff:fe3e:6d23", 0, 0, 6) # Your IP here! HTTP_PORT = 8001 class RenderingControlService(UpnpServerService): """Rendering Control service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:RenderingControl", service_type="urn:schemas-upnp-org:service:RenderingControl:1", control_url="/upnp/control/RenderingControl1", event_sub_url="/upnp/event/RenderingControl1", scpd_url="/RenderingControl_1.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "Volume": StateVariableTypeInfo( data_type="ui2", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui2"], default_value="0", allowed_value_range={ "min": "0", "max": "100", }, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "Mute": StateVariableTypeInfo( data_type="boolean", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["boolean"], default_value="0", allowed_value_range={}, allowed_values=[ "0", "1", ], xml=ET.Element("server_stateVariable"), ), "A_ARG_TYPE_InstanceID": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "A_ARG_TYPE_Channel": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), } @callable_action( name="GetVolume", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", "Channel": "A_ARG_TYPE_Channel", }, out_args={ "CurrentVolume": "Volume", }, ) async def get_volume( self, InstanceID: int, Channel: str ) -> Dict[str, UpnpStateVariable]: """Get Volume.""" # pylint: disable=invalid-name, unused-argument return { "CurrentVolume": self.state_variable("Volume"), } @callable_action( name="SetVolume", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", "Channel": "A_ARG_TYPE_Channel", "DesiredVolume": "Volume", }, out_args={}, ) async def set_volume( self, InstanceID: int, Channel: str, DesiredVolume: int ) -> Dict[str, UpnpStateVariable]: """Set Volume.""" # pylint: disable=invalid-name, unused-argument volume = self.state_variable("Volume") volume.value = DesiredVolume return {} @callable_action( name="GetMute", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", "Channel": "A_ARG_TYPE_Channel", }, out_args={ "CurrentMute": "Mute", }, ) async def get_mute( self, InstanceID: int, Channel: str ) -> Dict[str, UpnpStateVariable]: """Get Mute.""" # pylint: disable=invalid-name, unused-argument return { "CurrentMute": self.state_variable("Mute"), } @callable_action( name="SetMute", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", "Channel": "A_ARG_TYPE_Channel", "DesiredMute": "Mute", }, out_args={}, ) async def set_mute( self, InstanceID: int, Channel: str, DesiredMute: bool ) -> Dict[str, UpnpStateVariable]: """Set Volume.""" # pylint: disable=invalid-name, unused-argument volume = self.state_variable("Mute") volume.value = DesiredMute return {} class AVTransportService(UpnpServerService): """AVTransport service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:AVTransport", service_type="urn:schemas-upnp-org:service:AVTransport:1", control_url="/upnp/control/AVTransport1", event_sub_url="/upnp/event/AVTransport1", scpd_url="/AVTransport_1.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "A_ARG_TYPE_InstanceID": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "CurrentTrackURI": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "CurrentTrack": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "AVTransportURI": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "TransportState": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="STOPPED", allowed_value_range={}, allowed_values=[ "STOPPED", "PLAYING", "PAUSED_PLAYBACK", "TRANSITIONING", ], xml=ET.Element("server_stateVariable"), ), "TransportStatus": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "TransportPlaySpeed": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="1", allowed_value_range={}, allowed_values=["1"], xml=ET.Element("server_stateVariable"), ), "PossiblePlaybackStorageMedia": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="NOT_IMPLEMENTED", allowed_value_range={}, allowed_values=["NOT_IMPLEMENTED"], xml=ET.Element("server_stateVariable"), ), "PossibleRecordStorageMedia": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="NOT_IMPLEMENTED", allowed_value_range={}, allowed_values=["NOT_IMPLEMENTED"], xml=ET.Element("server_stateVariable"), ), "PossibleRecordQualityModes": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="NOT_IMPLEMENTED", allowed_value_range={}, allowed_values=["NOT_IMPLEMENTED"], xml=ET.Element("server_stateVariable"), ), "CurrentPlayMode": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="NORMAL", allowed_value_range={}, allowed_values=["NORMAL"], xml=ET.Element("server_stateVariable"), ), "CurrentRecordQualityMode": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="NOT_IMPLEMENTED", allowed_value_range={}, allowed_values=["NOT_IMPLEMENTED"], xml=ET.Element("server_stateVariable"), ), } @callable_action( name="GetTransportInfo", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", }, out_args={ "CurrentTransportState": "TransportState", "CurrentTransportStatus": "TransportStatus", "CurrentSpeed": "TransportPlaySpeed", }, ) async def get_transport_info(self, InstanceID: int) -> Dict[str, UpnpStateVariable]: """Get Transport Info.""" # pylint: disable=invalid-name, unused-argument return { "CurrentTransportState": self.state_variable("TransportState"), "CurrentTransportStatus": self.state_variable("TransportStatus"), "CurrentSpeed": self.state_variable("TransportPlaySpeed"), } @callable_action( name="GetMediaInfo", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", }, out_args={ "CurrentURI": "AVTransportURI", }, ) async def get_media_info(self, InstanceID: int) -> Dict[str, UpnpStateVariable]: """Get Media Info.""" # pylint: disable=invalid-name, unused-argument return { "CurrentURI": self.state_variable("AVTransportURI"), } @callable_action( name="GetDeviceCapabilities", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", }, out_args={ "PlayMedia": "PossiblePlaybackStorageMedia", "RecMedia": "PossibleRecordStorageMedia", "RecQualityModes": "PossibleRecordQualityModes", }, ) async def get_device_capabilities( self, InstanceID: int ) -> Dict[str, UpnpStateVariable]: """Get Device Capabilities.""" # pylint: disable=invalid-name, unused-argument return { "PlayMedia": self.state_variable("PossiblePlaybackStorageMedia"), "RecMedia": self.state_variable("PossibleRecordStorageMedia"), "RecQualityModes": self.state_variable("PossibleRecordQualityModes"), } @callable_action( name="GetTransportSettings", in_args={ "InstanceID": "A_ARG_TYPE_InstanceID", }, out_args={ "PlayMode": "CurrentPlayMode", "RecQualityMode": "CurrentRecordQualityMode", }, ) async def get_transport_settings( self, InstanceID: int ) -> Dict[str, UpnpStateVariable]: """Get Transport Settings.""" # pylint: disable=invalid-name, unused-argument return { "PlayMode": self.state_variable("CurrentPlayMode"), "RecQualityMode": self.state_variable("CurrentRecordQualityMode"), } class ConnectionManagerService(UpnpServerService): """ConnectionManager service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:ConnectionManager", service_type="urn:schemas-upnp-org:service:ConnectionManager:1", control_url="/upnp/control/ConnectionManager1", event_sub_url="/upnp/event/ConnectionManager1", scpd_url="/ConnectionManager_1.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "A_ARG_TYPE_InstanceID": StateVariableTypeInfo( data_type="ui4", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["ui4"], default_value=None, allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "SourceProtocolInfo": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), "SinkProtocolInfo": StateVariableTypeInfo( data_type="string", data_type_mapping=STATE_VARIABLE_TYPE_MAPPING["string"], default_value="", allowed_value_range={}, allowed_values=None, xml=ET.Element("server_stateVariable"), ), } @callable_action( name="GetProtocolInfo", in_args={}, out_args={ "Source": "SourceProtocolInfo", "Sink": "SinkProtocolInfo", }, ) async def get_protocol_info(self) -> Dict[str, UpnpStateVariable]: """Get Transport Settings.""" # pylint: disable=invalid-name, unused-argument return { "Source": self.state_variable("SourceProtocolInfo"), "Sink": self.state_variable("SinkProtocolInfo"), } class MediaRendererDevice(UpnpServerDevice): """Media Renderer device.""" DEVICE_DEFINITION = DeviceInfo( device_type="urn:schemas-upnp-org:device:MediaRenderer:1", friendly_name="Dummy TV", manufacturer="Steven", manufacturer_url=None, model_name="DummyTV v1", model_url=None, udn="uuid:ea2181c0-c677-4a09-80e6-f9e69a951284", upc=None, model_description="Dummy TV DMR", model_number="v0.0.1", serial_number="0000001", presentation_url=None, url="/device.xml", icons=[], xml=ET.Element("server_device"), ) EMBEDDED_DEVICES: Sequence[Type[UpnpServerDevice]] = [] SERVICES = [RenderingControlService, AVTransportService, ConnectionManagerService] def __init__(self, requester: UpnpRequester, base_uri: str, boot_id: int, config_id: int) -> None: """Initialize.""" super().__init__( requester=requester, base_uri=base_uri, boot_id=boot_id, config_id=config_id, ) async def async_main(server: UpnpServer) -> None: """Main.""" await server.async_start() while True: await asyncio.sleep(3600) async def async_stop(server: UpnpServer) -> None: await server.async_stop() loop = asyncio.get_event_loop() loop.run_until_complete() if __name__ == "__main__": boot_id = int(time()) config_id = 1 server = UpnpServer(MediaRendererDevice, SOURCE, http_port=HTTP_PORT, boot_id=boot_id, config_id=config_id) try: asyncio.run(async_main(server)) except KeyboardInterrupt: print(KeyboardInterrupt) asyncio.run(server.async_stop()) async_upnp_client-0.44.0/contrib/media_server.py000066400000000000000000000164451477256211100220130ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Dummy mediaseerver.""" # Instructions: # - Change `SOURCE``. When using IPv6, be sure to set the scope_id, the last value in the tuple. # - Run this module. # - Run upnp-client (change IP to your own IP): # upnp-client call-action 'http://0.0.0.0:8000/device.xml' \ # WANCIC/GetTotalPacketsReceived import asyncio import logging import xml.etree.ElementTree as ET from time import time from typing import Dict, Mapping, Sequence, Type from datetime import datetime from async_upnp_client.client import UpnpRequester, UpnpStateVariable from async_upnp_client.const import ( STATE_VARIABLE_TYPE_MAPPING, DeviceInfo, ServiceInfo, StateVariableTypeInfo, EventableStateVariableTypeInfo, ) from async_upnp_client.server import ( UpnpServer, UpnpServerDevice, UpnpServerService, callable_action, create_state_var, create_event_var) logging.basicConfig(level=logging.DEBUG) LOGGER = logging.getLogger("dummy_mediaserver") LOGGER_SSDP_TRAFFIC = logging.getLogger("async_upnp_client.traffic") LOGGER_SSDP_TRAFFIC.setLevel(logging.WARNING) SOURCE = ("192.168.1.85", 0) # Your IP here! # SOURCE = ("fe80::215:5dff:fe3e:6d23", 0, 0, 6) # Your IP here! HTTP_PORT = 8000 class ContentDirectoryService(UpnpServerService): """DLNA Content Directory.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:ContentDirectory", service_type="urn:schemas-upnp-org:service:ContentDirectory:2", control_url="/upnp/control/ContentDirectory", event_sub_url="/upnp/event/ContentDirectory", scpd_url="/ContentDirectory.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "SearchCapabilities": create_state_var("string"), "SortCapabilities": create_state_var("string"), "SystemUpdateID": create_event_var("ui4", max_rate=0.2), "FeatureList": create_state_var("string", default=""" """), "A_ARG_TYPE_BrowseFlag": create_state_var("string", allowed=["BrowseMetadata", "BrowseDirectChildren"]), "A_ARG_TYPE_Filter": create_state_var("string"), "A_ARG_TYPE_ObjectID": create_state_var("string"), "A_ARG_TYPE_Count": create_state_var("ui4"), "A_ARG_TYPE_Index": create_state_var("ui4"), "A_ARG_TYPE_SortCriteria": create_state_var("string"), ### "A_ARG_TYPE_Result": create_state_var("string"), "A_ARG_TYPE_UpdateID": create_state_var("ui4"), "A_ARG_TYPE_Count_NumberReturned": create_state_var("ui4"), "A_ARG_TYPE_Count_TotalMatches": create_state_var("ui4"), } @callable_action( name="Browse", in_args={ "BrowseFlag": "A_ARG_TYPE_BrowseFlag", "Filter": "A_ARG_TYPE_Filter", "ObjectID": "A_ARG_TYPE_ObjectID", "RequestedCount": "A_ARG_TYPE_Count", "SortCriteria": "A_ARG_TYPE_SortCriteria", "StartingIndex": "A_ARG_TYPE_Index", }, out_args={ "Result": "A_ARG_TYPE_Result", "NumberReturned": "A_ARG_TYPE_Count_NumberReturned", "TotalMatches": "A_ARG_TYPE_Count_TotalMatches", "UpdateID": "A_ARG_TYPE_UpdateID", }, ) async def browse(self, BrowseFlag: str, Filter: str, ObjectID: str, StartingIndex: int, RequestedCount: int, SortCriteria: str) -> Dict[str, UpnpStateVariable]: """Browse media.""" root = ET.Element("DIDL-Lite", { 'xmlns:dc': 'http://purl.org/dc/elements/1.1/', 'xmlns:upnp': 'urn:schemas-upnp-org:metadata-1-0/upnp/', 'DIDL-Lite': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/'}) child = ET.Element('item', {'id': f'100', 'restricted': '0'}) ET.SubElement(child, 'dc:title').text = "Item 1" ET.SubElement(child, 'dc:date').text = datetime.now().isoformat() root.append(child) xml = ET.tostring(root).decode() return { "Result": xml, "NumberReturned": 1, "TotalMatches": 2, } @callable_action( name="GetSearchCapabilities", in_args={}, out_args={ "SearchCaps": "SearchCapabilities", }, ) async def GetSearchCapabilities(self) -> Dict[str, UpnpStateVariable]: """Browse media.""" return { "SearchCaps": self.state_variable("SearchCapabilities"), } @callable_action( name="GetSortCapabilities", in_args={}, out_args={ "SortCaps": "SortCapabilities", }, ) async def GetSortCapabilities(self) -> Dict[str, UpnpStateVariable]: """Browse media.""" return { "SortCaps": self.state_variable("SortCapabilities"), } @callable_action( name="GetFeatureList", in_args={}, out_args={ "FeatureList": "FeatureList", }, ) async def GetFeatureList(self) -> Dict[str, UpnpStateVariable]: """Browse media.""" return { "FeatureList": self.state_variable("FeatureList"), } @callable_action( name="GetSystemUpdateID", in_args={}, out_args={ "Id": "SystemUpdateID", }, ) async def GetSystemUpdateID(self) -> Dict[str, UpnpStateVariable]: """Browse media.""" return { "Id": self.state_variable("SystemUpdateID"), } class MediaServerDevice(UpnpServerDevice): """Media Server Device.""" DEVICE_DEFINITION = DeviceInfo( device_type=":urn:schemas-upnp-org:device:MediaServer:2", friendly_name="Media Server v1", manufacturer="Steven", manufacturer_url=None, model_name="MediaServer v1", model_url=None, udn="uuid:1cd38bfe-3c10-403e-a97f-2bc5c1652b9a", upc=None, model_description="Media Server", model_number="v0.0.1", serial_number="0000001", presentation_url=None, url="/device.xml", icons=[], xml=ET.Element("server_device"), ) EMBEDDED_DEVICES = [] SERVICES = [ContentDirectoryService] def __init__(self, requester: UpnpRequester, base_uri: str, boot_id: int, config_id: int) -> None: """Initialize.""" super().__init__( requester=requester, base_uri=base_uri, boot_id=boot_id, config_id=config_id, ) async def async_main(server: UpnpServer) -> None: """Main.""" await server.async_start() while True: await asyncio.sleep(3600) async def async_stop(server: UpnpServer) -> None: await server.async_stop() loop = asyncio.get_event_loop() loop.run_until_complete() if __name__ == "__main__": boot_id = int(time()) config_id = 1 server = UpnpServer(MediaServerDevice, SOURCE, http_port=HTTP_PORT, boot_id=boot_id, config_id=config_id) try: asyncio.run(async_main(server)) except KeyboardInterrupt: print(KeyboardInterrupt) asyncio.run(server.async_stop()) async_upnp_client-0.44.0/contrib/monitor_igd_traffic.sh000077500000000000000000000027061477256211100233360ustar00rootroot00000000000000#!/usr/bin/env bash # Example script to monitor traffic count (bytes in/bytes out) on my IGD # Requires: # - jq (install by: sudo apt-get install jq) # - async_upnp_client (install by: pip install async_pnp_client) set -e if [ "${1}" = "" ]; then echo "Usage: ${0} url-to-device-description" exit 1 fi # we want thousands separator export LC_NUMERIC=en_US.UTF-8 UPNP_DEVICE_DESC=${1} UPNP_ACTION_RECEIVED=WANCIFC/GetTotalBytesReceived UPNP_ACTION_SENT=WANCIFC/GetTotalBytesSent JQ_QUERY_RECEIVED=.out_parameters.NewTotalBytesReceived JQ_QUERY_SENT=.out_parameters.NewTotalBytesSent SLEEP_TIME=1 function get_bytes_received { echo $(upnp-client --device ${UPNP_DEVICE_DESC} call-action ${UPNP_ACTION_RECEIVED} | jq "${JQ_QUERY_RECEIVED}") } function get_bytes_sent { echo $(upnp-client --device ${UPNP_DEVICE_DESC} call-action ${UPNP_ACTION_SENT} | jq "${JQ_QUERY_SENT}") } # print header printf "%-*s %*s %*s\n" 24 "Timestamp" 16 "Received" 16 "Sent" BYTES_RECEIVED=$(get_bytes_received) BYTES_SENT=$(get_bytes_sent) while [ true ]; do sleep ${SLEEP_TIME} PREV_BYTES_RECEIVED=${BYTES_RECEIVED} PREV_BYTES_SENT=${BYTES_SENT} BYTES_RECEIVED=$(get_bytes_received) BYTES_SENT=$(get_bytes_sent) DIFF_BYTES_RECEIVED=$((${BYTES_RECEIVED} - ${PREV_BYTES_RECEIVED})) DIFF_BYTES_SENT=$((${BYTES_SENT} - ${PREV_BYTES_SENT})) DATE=$(date "+%Y-%m-%d %H:%M:%S") printf "%-*s %'*.0f %'*.0f\n" 24 "${DATE}" 16 "${DIFF_BYTES_RECEIVED}" 16 "${DIFF_BYTES_SENT}" done async_upnp_client-0.44.0/examples/000077500000000000000000000000001477256211100171405ustar00rootroot00000000000000async_upnp_client-0.44.0/examples/adding_deleting_pinhole.py000066400000000000000000000047561477256211100243450ustar00rootroot00000000000000#!/usr/bin/env python3 """Example of adding and deleting a port mapping.""" import asyncio import ipaddress import sys from datetime import timedelta from typing import cast from async_upnp_client.aiohttp import AiohttpRequester from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.profiles.igd import IgdDevice from async_upnp_client.utils import CaseInsensitiveDict, get_local_ip SOURCE = ("0.0.0.0", 0) async def discover_igd_devices() -> set[CaseInsensitiveDict]: """Discover IGD devices.""" # Do the search, this blocks for timeout (4 seconds, default). discoveries = await IgdDevice.async_search(source=SOURCE) if not discoveries: print("Could not find device") sys.exit(1) return discoveries async def build_igd_device(discovery: CaseInsensitiveDict) -> IgdDevice: """Find and construct device.""" location = discovery["location"] requester = AiohttpRequester() factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device(description_url=location) return IgdDevice(device, None) async def async_add_pinhole(igd_device: IgdDevice) -> int: """Add Pinhole.""" remote_host: ipaddress.IPv6Address = ipaddress.ip_address("::1") internal_client: ipaddress.IPv6Address = ipaddress.ip_address("fe80::1") protocol = 6 # TCP=6, UDP=17 pinhole_id = await igd_device.async_add_pinhole( remote_host=remote_host, remote_port=43210, internal_client=internal_client, internal_port=54321, protocol=protocol, lease_time=timedelta(seconds=7200), ) return pinhole_id async def async_del_pinhole(igd_device: IgdDevice, pinhole_id: int) -> None: """Delete port mapping.""" await igd_device.async_delete_pinhole( pinhole_id=pinhole_id, ) async def async_main() -> None: """Async main.""" discoveries = await discover_igd_devices() print(f"Discoveries: {discoveries}") discovery = list(discoveries)[0] print(f"Using device at location: {discovery['location']}") igd_device = await build_igd_device(discovery) print("Creating pinhole") pinhole_id = await async_add_pinhole(igd_device) print("Pinhole ID:", pinhole_id) await asyncio.sleep(5) print("Deleting pinhole") await async_del_pinhole(igd_device, pinhole_id) def main() -> None: """Main.""" try: asyncio.run(async_main()) except KeyboardInterrupt: pass if __name__ == "__main__": main() async_upnp_client-0.44.0/examples/adding_deleting_port_mapping.py000066400000000000000000000063531477256211100254010ustar00rootroot00000000000000#!/usr/bin/env python3 """Example of adding and deleting a port mapping.""" import asyncio import ipaddress import sys from datetime import timedelta from typing import cast from async_upnp_client.aiohttp import AiohttpRequester from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.profiles.igd import IgdDevice from async_upnp_client.utils import CaseInsensitiveDict, get_local_ip SOURCE = ("0.0.0.0", 0) async def discover_igd_devices() -> set[CaseInsensitiveDict]: """Discover IGD devices.""" # Do the search, this blocks for timeout (4 seconds, default). discoveries = await IgdDevice.async_search(source=SOURCE) if not discoveries: print("Could not find device") sys.exit(1) return discoveries async def build_igd_device(discovery: CaseInsensitiveDict) -> IgdDevice: """Find and construct device.""" location = discovery["location"] requester = AiohttpRequester() factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device(description_url=location) return IgdDevice(device, None) async def async_add_port_mapping(igd_device: IgdDevice) -> None: """Add port mapping.""" external_ip_address = await igd_device.async_get_external_ip_address() if not external_ip_address: print("Could not get external IP address") sys.exit(1) remote_host = ipaddress.ip_address(external_ip_address) remote_host_ipv4 = cast(ipaddress.IPv4Address, remote_host) local_ip = ipaddress.ip_address(get_local_ip()) local_ip_ipv4 = cast(ipaddress.IPv4Address, local_ip) # Change `enabled` to False to disable port mapping. # NB: This does not delete the port mapping. enabled = True mapping_name = "Bombsquad" await igd_device.async_add_port_mapping( remote_host=remote_host_ipv4, external_port=43210, internal_client=local_ip_ipv4, internal_port=43210, protocol="UDP", enabled=enabled, description=mapping_name, lease_duration=timedelta(seconds=7200), ) # Time in secs async def async_del_port_mapping(igd_device: IgdDevice) -> None: """Delete port mapping.""" external_ip_address = await igd_device.async_get_external_ip_address() if not external_ip_address: print("Could not get external IP address") sys.exit(1) remote_host = ipaddress.ip_address(external_ip_address) remote_host_ipv4 = cast(ipaddress.IPv4Address, remote_host) await igd_device.async_delete_port_mapping( remote_host=remote_host_ipv4, external_port=43210, protocol="UDP", ) async def async_main() -> None: """Async main.""" discoveries = await discover_igd_devices() print(f"Discoveries: {discoveries}") discovery = list(discoveries)[0] print(f"Using device at location: {discovery['location']}") igd_device = await build_igd_device(discovery) print("Creating port mapping") await async_add_port_mapping(igd_device) await asyncio.sleep(5) print("Deleting port mapping") await async_del_port_mapping(igd_device) def main() -> None: """Main.""" try: asyncio.run(async_main()) except KeyboardInterrupt: pass if __name__ == "__main__": main() async_upnp_client-0.44.0/examples/get_volume.py000066400000000000000000000023401477256211100216570ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- """ Example to get the current volume from a DLNA/DMR capable TV. Change the target variable below to point at your TV. Use, for example, something like netdisco to discover the URL for the service. You can run contrib/dummy_tv.py locally to emulate a TV. """ import asyncio import logging from async_upnp_client.aiohttp import AiohttpRequester from async_upnp_client.client_factory import UpnpFactory logging.basicConfig(level=logging.INFO) target = "http://192.168.178.11:49152/description.xml" async def main(): # create the factory requester = AiohttpRequester() factory = UpnpFactory(requester) # create a device device = await factory.async_create_device(target) print("Device: {}".format(device)) # get RenderingControle-service service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") print("Service: {}".format(service)) # perform GetVolume action get_volume = service.action("GetVolume") print("Action: {}".format(get_volume)) result = await get_volume.async_call(InstanceID=0, Channel="Master") print("Action result: {}".format(result)) loop = asyncio.get_event_loop() loop.run_until_complete(main()) async_upnp_client-0.44.0/setup.cfg000066400000000000000000000050031477256211100171410ustar00rootroot00000000000000[bumpversion] current_version = 0.44.0 commit = True tag = False tag_name = {new_version} [bumpversion:file:async_upnp_client/__init__.py] search = __version__ = "{current_version}" replace = __version__ = "{new_version}" [metadata] name = async_upnp_client version = attr: async_upnp_client.__version__ description = Async UPnP Client long_description = file: README.rst long_description_content_type = text/x-rst url = https://github.com/StevenLooman/async_upnp_client project_urls = GitHub: repo = https://github.com/StevenLooman/async_upnp_client author = Steven Looman author_email = steven.looman@gmail.com license = Apache 2 license_file = LICENSE.txt classifiers = Development Status :: 5 - Production/Stable Intended Audience :: Developers License :: OSI Approved :: Apache Software License Framework :: AsyncIO Operating System :: POSIX Operating System :: MacOS :: MacOS X Operating System :: Microsoft :: Windows Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 keywords = ssdp Simple Service Discovery Protocol upnp Universal Plug and Play [options] python_requires = >=3.9 install_requires = voluptuous >= 0.15.2 aiohttp >3.9.0, <4.0 async-timeout >=3.0, <6.0 python-didl-lite ~= 1.4.0 defusedxml >= 0.6.0 tests_require = pytest ~= 8.3.3 pytest-asyncio >= 0.24,< 0.26 pytest-aiohttp >= 1.0.5,< 1.2.0 pytest-cov >= 5.0,< 6.1 coverage >= 7.6.1,< 7.8.0 asyncmock ~= 0.4.2 packages = async_upnp_client async_upnp_client.profiles [options.entry_points] console_scripts = upnp-client = async_upnp_client.cli:main [options.package_data] async-upnp-client = py.typed [bdist_wheel] python-tag = py3 [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build max-line-length = 119 max-complexity = 25 ignore = E501, W503, E203, D202, W504 noqa-require-code = True [tool:pytest] asyncio_mode = auto asyncio_default_fixture_loop_scope = function [mypy] check_untyped_defs = true disallow_untyped_calls = true disallow_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_decorators = true no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true [codespell] ignore-words-list = wan [pylint.SIMILARITIES] min-similarity-lines = 8 [coverage:run] source = async_upnp_client omit = async_upnp_client/aiohttp.py async_upnp_client/cli.py async_upnp_client-0.44.0/setup.py000077500000000000000000000000641477256211100170370ustar00rootroot00000000000000"""Setup.""" from setuptools import setup setup() async_upnp_client-0.44.0/tests/000077500000000000000000000000001477256211100164645ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/__init__.py000066400000000000000000000000431477256211100205720ustar00rootroot00000000000000"""Tests for async_upnp_client.""" async_upnp_client-0.44.0/tests/common.py000066400000000000000000000025151477256211100203310ustar00rootroot00000000000000"""Common test parts.""" from datetime import datetime from async_upnp_client.utils import CaseInsensitiveDict ADVERTISEMENT_REQUEST_LINE = "NOTIFY * HTTP/1.1" ADVERTISEMENT_HEADERS_DEFAULT = CaseInsensitiveDict( { "CACHE-CONTROL": "max-age=1800", "NTS": "ssdp:alive", "NT": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "USN": "uuid:...::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "LOCATION": "http://192.168.1.1:80/RootDevice.xml", "BOOTID.UPNP.ORG": "1", "SERVER": "Linux/2.0 UPnP/1.0 async_upnp_client/0.1", "_timestamp": datetime.now(), "_host": "192.168.1.1", "_port": "1900", "_udn": "uuid:...", } ) SEARCH_REQUEST_LINE = "HTTP/1.1 200 OK" SEARCH_HEADERS_DEFAULT = CaseInsensitiveDict( { "CACHE-CONTROL": "max-age=1800", "ST": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "USN": "uuid:...::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "LOCATION": "http://192.168.1.1:80/RootDevice.xml", "BOOTID.UPNP.ORG": "1", "SERVER": "Linux/2.0 UPnP/1.0 async_upnp_client/0.1", "DATE": "Fri, 1 Jan 2021 12:00:00 GMT", "_timestamp": datetime.now(), "_host": "192.168.1.1", "_port": "1900", "_udn": "uuid:...", } ) async_upnp_client-0.44.0/tests/conftest.py000066400000000000000000000152431477256211100206700ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Profiles for upnp_client.""" import asyncio import os.path from collections import deque from copy import deepcopy from typing import Deque, Mapping, MutableMapping, Optional, Tuple, cast from async_upnp_client.client import UpnpRequester from async_upnp_client.const import AddressTupleVXType, HttpRequest, HttpResponse from async_upnp_client.event_handler import UpnpEventHandler, UpnpNotifyServer def read_file(filename: str) -> str: """Read file.""" path = os.path.join("tests", "fixtures", filename) with open(path, encoding="utf-8") as file: return file.read() class UpnpTestRequester(UpnpRequester): """Test requester.""" # pylint: disable=too-few-public-methods def __init__( self, response_map: Mapping[Tuple[str, str], HttpResponse], ) -> None: """Class initializer.""" self.response_map: MutableMapping[Tuple[str, str], HttpResponse] = deepcopy( cast(MutableMapping, response_map) ) self.exceptions: Deque[Optional[Exception]] = deque() async def async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request.""" await asyncio.sleep(0.01) if self.exceptions: exception = self.exceptions.popleft() if exception is not None: raise exception key = (http_request.method, http_request.url) if key not in self.response_map: raise KeyError(f"Request not in response map: {key}") return self.response_map[key] RESPONSE_MAP: Mapping[Tuple[str, str], HttpResponse] = { # DLNA/DMR ("GET", "http://dlna_dmr:1234/device.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/device.xml"), ), ("GET", "http://dlna_dmr:1234/device_embedded.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/device_embedded.xml"), ), ("GET", "http://dlna_dmr:1234/device_incomplete.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/device_incomplete.xml"), ), ("GET", "http://dlna_dmr:1234/device_with_empty_descriptor.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/device_with_empty_descriptor.xml"), ), ("GET", "http://dlna_dmr:1234/RenderingControl_1.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/RenderingControl_1.xml"), ), ("GET", "http://dlna_dmr:1234/ConnectionManager_1.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/ConnectionManager_1.xml"), ), ("GET", "http://dlna_dmr:1234/AVTransport_1.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/AVTransport_1.xml"), ), ("GET", "http://dlna_dmr:1234/Empty_Descriptor.xml"): HttpResponse( 200, {}, read_file("dlna/dmr/Empty_Descriptor.xml"), ), ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/ConnectionManager1"): HttpResponse( 200, {"sid": "uuid:dummy-cm1", "timeout": "Second-175"}, "", ), ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1"): HttpResponse( 200, {"sid": "uuid:dummy", "timeout": "Second-300"}, "", ), ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/AVTransport1"): HttpResponse( 200, {"sid": "uuid:dummy-avt1", "timeout": "Second-150"}, "", ), ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/QPlay"): HttpResponse( 200, {"sid": "uuid:dummy-qp1", "timeout": "Second-150"}, "", ), ("UNSUBSCRIBE", "http://dlna_dmr:1234/upnp/event/ConnectionManager1"): HttpResponse( 200, {"sid": "uuid:dummy-cm1"}, "", ), ("UNSUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1"): HttpResponse( 200, {"sid": "uuid:dummy"}, "", ), ("UNSUBSCRIBE", "http://dlna_dmr:1234/upnp/event/AVTransport1"): HttpResponse( 200, {"sid": "uuid:dummy-avt1"}, "", ), ("UNSUBSCRIBE", "http://dlna_dmr:1234/upnp/event/QPlay"): HttpResponse( 200, {"sid": "uuid:dummy-qp1"}, "", ), # DLNA/DMS ("GET", "http://dlna_dms:1234/device.xml"): HttpResponse( 200, {}, read_file("dlna/dms/device.xml"), ), ("GET", "http://dlna_dms:1234/ConnectionManager_1.xml"): HttpResponse( 200, {}, read_file("dlna/dms/ConnectionManager_1.xml"), ), ("GET", "http://dlna_dms:1234/ContentDirectory_1.xml"): HttpResponse( 200, {}, read_file("dlna/dms/ContentDirectory_1.xml"), ), ("SUBSCRIBE", "http://dlna_dms:1234/upnp/event/ConnectionManager1"): HttpResponse( 200, {"sid": "uuid:dummy-cm1", "timeout": "Second-150"}, "", ), ("SUBSCRIBE", "http://dlna_dms:1234/upnp/event/ContentDirectory1"): HttpResponse( 200, {"sid": "uuid:dummy-cd1", "timeout": "Second-150"}, "", ), ("UNSUBSCRIBE", "http://dlna_dms:1234/upnp/event/ConnectionManager1"): HttpResponse( 200, {"sid": "uuid:dummy-cm1"}, "", ), ("UNSUBSCRIBE", "http://dlna_dms:1234/upnp/event/ContentDirectory1"): HttpResponse( 200, {"sid": "uuid:dummy-cd1"}, "", ), # IGD ("GET", "http://igd:1234/device.xml"): HttpResponse( 200, {}, read_file("igd/device.xml") ), ("GET", "http://igd:1234/Layer3Forwarding.xml"): HttpResponse( 200, {}, read_file("igd/Layer3Forwarding.xml"), ), ("GET", "http://igd:1234/WANCommonInterfaceConfig.xml"): HttpResponse( 200, {}, read_file("igd/WANCommonInterfaceConfig.xml"), ), ("GET", "http://igd:1234/WANIPConnection.xml"): HttpResponse( 200, {}, read_file("igd/WANIPConnection.xml"), ), } class UpnpTestNotifyServer(UpnpNotifyServer): """Test notify server.""" def __init__( self, requester: UpnpRequester, source: AddressTupleVXType, callback_url: Optional[str] = None, ) -> None: """Initialize.""" self._requester = requester self._source = source self._callback_url = callback_url self.event_handler = UpnpEventHandler(self, requester) @property def callback_url(self) -> str: """Return callback URL on which we are callable.""" return ( self._callback_url or f"http://{self._source[0]}:{self._source[1]}/notify" ) async def async_start_server(self) -> None: """Start the server.""" async def async_stop_server(self) -> None: """Stop the server.""" await self.event_handler.async_unsubscribe_all() async_upnp_client-0.44.0/tests/fixtures/000077500000000000000000000000001477256211100203355ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/fixtures/dlna/000077500000000000000000000000001477256211100212535ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/000077500000000000000000000000001477256211100220355ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/AVTransport_1.xml000066400000000000000000000525551477256211100252360ustar00rootroot00000000000000 1 0 Play InstanceID in A_ARG_TYPE_InstanceID Speed in TransportPlaySpeed Stop InstanceID in A_ARG_TYPE_InstanceID Next InstanceID in A_ARG_TYPE_InstanceID Previous InstanceID in A_ARG_TYPE_InstanceID SetPlayMode InstanceID in A_ARG_TYPE_InstanceID NewPlayMode in CurrentPlayMode GetMediaInfo InstanceID in A_ARG_TYPE_InstanceID NrTracks out NumberOfTracks 0 MediaDuration out CurrentMediaDuration CurrentURI out AVTransportURI CurrentURIMetaData out AVTransportURIMetaData NextURI out NextAVTransportURI NextURIMetaData out NextAVTransportURIMetaData PlayMedium out PlaybackStorageMedium RecordMedium out RecordStorageMedium WriteStatus out RecordMediumWriteStatus GetDeviceCapabilities InstanceID in A_ARG_TYPE_InstanceID PlayMedia out PossiblePlaybackStorageMedia RecMedia out PossibleRecordStorageMedia RecQualityModes out PossibleRecordQualityModes SetAVTransportURI InstanceID in A_ARG_TYPE_InstanceID CurrentURI in AVTransportURI CurrentURIMetaData in AVTransportURIMetaData SetNextAVTransportURI InstanceID in A_ARG_TYPE_InstanceID NextURI in NextAVTransportURI NextURIMetaData in NextAVTransportURIMetaData X_PrefetchURI InstanceID in A_ARG_TYPE_InstanceID PrefetchURI in A_ARG_TYPE_PrefetchURI PrefetchURIMetaData in A_ARG_TYPE_PrefetchURIMetaData GetTransportSettings InstanceID in A_ARG_TYPE_InstanceID PlayMode out CurrentPlayMode RecQualityMode out CurrentRecordQualityMode GetTransportInfo InstanceID in A_ARG_TYPE_InstanceID CurrentTransportState out TransportState CurrentTransportStatus out TransportStatus CurrentSpeed out TransportPlaySpeed Pause InstanceID in A_ARG_TYPE_InstanceID Seek InstanceID in A_ARG_TYPE_InstanceID Unit in A_ARG_TYPE_SeekMode Target in A_ARG_TYPE_SeekTarget GetPositionInfo InstanceID in A_ARG_TYPE_InstanceID Track out CurrentTrack TrackDuration out CurrentTrackDuration TrackMetaData out CurrentTrackMetaData TrackURI out CurrentTrackURI RelTime out RelativeTimePosition AbsTime out AbsoluteTimePosition RelCount out RelativeCounterPosition AbsCount out AbsoluteCounterPosition GetCurrentTransportActions InstanceID in A_ARG_TYPE_InstanceID Actions out CurrentTransportActions X_DLNA_GetBytePositionInfo InstanceID in A_ARG_TYPE_InstanceID TrackSize out X_DLNA_CurrentTrackSize RelByte out X_DLNA_RelativeBytePosition AbsByte out X_DLNA_AbsoluteBytePosition X_GetStoppedReason InstanceID in A_ARG_TYPE_InstanceID StoppedReason out A_ARG_TYPE_StoppedReason StoppedReasonData out A_ARG_TYPE_StoppedReasonData X_PlayerAppHint InstanceID in A_ARG_TYPE_InstanceID UpnpClass in X_ARG_TYPE_UpnpClass PlayerHint in X_ARG_TYPE_PlayerHint TransportState string STOPPED PAUSED_PLAYBACK PLAYING TRANSITIONING NO_MEDIA_PRESENT NO_MEDIA_PRESENT TransportStatus string OK ERROR_OCCURRED OK TransportPlaySpeed string 1 NumberOfTracks ui4 0 4294967295 CurrentMediaDuration string 00:00:00 AVTransportURI string AVTransportURIMetaData string PlaybackStorageMedium string NONE NETWORK NONE CurrentTrack ui4 0 4294967295 1 0 CurrentTrackDuration string 00:00:00 CurrentTrackMetaData string CurrentTrackURI string RelativeTimePosition string 00:00:00 AbsoluteTimePosition string 00:00:00 NextAVTransportURI string NextAVTransportURIMetaData string CurrentTransportActions string RecordStorageMedium string NOT_IMPLEMENTED NOT_IMPLEMENTED RecordMediumWriteStatus string NOT_IMPLEMENTED NOT_IMPLEMENTED RelativeCounterPosition i4 2147483647 AbsoluteCounterPosition i4 2147483647 PossiblePlaybackStorageMedia string NETWORK PossibleRecordStorageMedia string NOT_IMPLEMENTED PossibleRecordQualityModes string NOT_IMPLEMENTED CurrentPlayMode string NORMAL NORMAL CurrentRecordQualityMode string NOT_IMPLEMENTED NOT_IMPLEMENTED LastChange string A_ARG_TYPE_InstanceID ui4 A_ARG_TYPE_PrefetchURI string A_ARG_TYPE_PrefetchURIMetaData string A_ARG_TYPE_SeekMode string TRACK_NR REL_TIME ABS_TIME ABS_COUNT REL_COUNT X_DLNA_REL_BYTE FRAME REL_TIME A_ARG_TYPE_SeekTarget string X_DLNA_RelativeBytePosition string X_DLNA_AbsoluteBytePosition string X_DLNA_CurrentTrackSize string A_ARG_TYPE_StoppedReason string A_ARG_TYPE_StoppedReasonData string X_ARG_TYPE_UpnpClass string object.item.imageItem object.item.audioItem object.item.videoItem X_ARG_TYPE_PlayerHint string load unload async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/ConnectionManager_1.xml000066400000000000000000000104731477256211100263760ustar00rootroot00000000000000 1 0 GetProtocolInfo Source out SourceProtocolInfo Sink out SinkProtocolInfo GetCurrentConnectionIDs ConnectionIDs out CurrentConnectionIDs GetCurrentConnectionInfo ConnectionID in A_ARG_TYPE_ConnectionID RcsID out A_ARG_TYPE_RcsID AVTransportID out A_ARG_TYPE_AVTransportID ProtocolInfo out A_ARG_TYPE_ProtocolInfo PeerConnectionManager out A_ARG_TYPE_ConnectionManager PeerConnectionID out A_ARG_TYPE_ConnectionID Direction out A_ARG_TYPE_Direction Status out A_ARG_TYPE_ConnectionStatus SourceProtocolInfo string SinkProtocolInfo string CurrentConnectionIDs string A_ARG_TYPE_ConnectionStatus string OK ContentFormatMismatch InsufficientBandwidth UnreliableChannel Unknown A_ARG_TYPE_ConnectionManager string A_ARG_TYPE_Direction string Input Output A_ARG_TYPE_ProtocolInfo string A_ARG_TYPE_ConnectionID i4 A_ARG_TYPE_AVTransportID i4 A_ARG_TYPE_RcsID i4 async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/Empty_Descriptor.xml000066400000000000000000000000001477256211100260410ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/RenderingControl_1.xml000066400000000000000000000075031477256211100262620ustar00rootroot00000000000000 1 0 GetMute InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel CurrentMute out Mute SetMute InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel DesiredMute in Mute GetVolume InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel CurrentVolume out Volume SetVolume InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel DesiredVolume in Volume LastChange string Mute boolean Volume ui2 0 100 1 A_ARG_TYPE_Channel string Master A_ARG_TYPE_InstanceID ui4 no SV1 dateTime SV2 dateTime.tz async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/RenderingControl_1_bad_namespace.xml000066400000000000000000000075061477256211100311070ustar00rootroot00000000000000 1 0 GetMute InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel CurrentMute out Mute SetMute InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel DesiredMute in Mute GetVolume InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel CurrentVolume out Volume SetVolume InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel DesiredVolume in Volume LastChange string Mute boolean Volume ui2 0 100 1 A_ARG_TYPE_Channel string Master A_ARG_TYPE_InstanceID ui4 no SV1 dateTime SV2 dateTime.tz async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/RenderingControl_1_bad_root_tag.xml000066400000000000000000000075111477256211100307650ustar00rootroot00000000000000 1 0 GetMute InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel CurrentMute out Mute SetMute InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel DesiredMute in Mute GetVolume InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel CurrentVolume out Volume SetVolume InstanceID in A_ARG_TYPE_InstanceID Channel in A_ARG_TYPE_Channel DesiredVolume in Volume LastChange string Mute boolean Volume ui2 0 100 1 A_ARG_TYPE_Channel string Master A_ARG_TYPE_InstanceID ui4 no SV1 dateTime SV2 dateTime.tz async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/RenderingControl_1_missing_state_table.xml000066400000000000000000000035321477256211100323600ustar00rootroot00000000000000 1 0 GetMute InstanceID in Channel in CurrentMute out SetMute InstanceID in Channel in DesiredMute in GetVolume InstanceID in Channel in CurrentVolume out SetVolume InstanceID in Channel in DesiredVolume in async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetCurrentTransportActions_PlaySeek.xml000066400000000000000000000005641477256211100332360ustar00rootroot00000000000000 Play,Seek async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetCurrentTransportActions_Stop.xml000066400000000000000000000005571477256211100324500ustar00rootroot00000000000000 Stop async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetMediaInfo.xml000066400000000000000000000023631477256211100264130ustar00rootroot00000000000000 1 00:00:01 uri://1.mp3 <DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/" xmlns:sec="http://www.sec.co.kr/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:xbmc="urn:schemas-xbmc-org:metadata-1-0/"><item id="" parentID="" refID="" restricted="1"><upnp:artist>A &amp; B &gt; C</upnp:artist></item></DIDL-Lite> NONE NOT_IMPLEMENTED NOT_IMPLEMENTED async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetPositionInfo.xml000066400000000000000000000023621477256211100271770ustar00rootroot00000000000000 1 00:03:14 <DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/" xmlns:sec="http://www.sec.co.kr/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:xbmc="urn:schemas-xbmc-org:metadata-1-0/"><item id="" parentID="" refID="" restricted="1"><upnp:class>object.item.audioItem.musicTrack</upnp:class><dc:title>Test track</dc:title><upnp:artist>A &amp; B &gt; C</upnp:artist></item></DIDL-Lite> uri://1.mp3 00:00:00 00:00:00 2147483647 2147483647 async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetTransportInfoInvalidServiceType.xml000066400000000000000000000007721477256211100330640ustar00rootroot00000000000000 STOPPED OK 1 async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetVolume.xml000066400000000000000000000005751477256211100260320ustar00rootroot00000000000000 3 async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetVolumeError.xml000066400000000000000000000011351477256211100270350ustar00rootroot00000000000000 s:Client UPnPError 402 Invalid Args async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetVolumeExtraOutParameter.xml000066400000000000000000000006521477256211100313630ustar00rootroot00000000000000 3 False async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_GetVolumeInvalidServiceType.xml000066400000000000000000000005731477256211100315220ustar00rootroot00000000000000 3 async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/action_SetVolume.xml000066400000000000000000000004631477256211100260420ustar00rootroot00000000000000 async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/device.xml000066400000000000000000000051311477256211100240160ustar00rootroot00000000000000 1 0 urn:schemas-upnp-org:device:MediaRenderer:1 Dummy TV Steven Dummy TV DMR DummyTV v1 uuid:00000000-0000-0000-0000-000000000000 image/jpeg 48 48 24 /device_icon_48.jpg image/jpeg 120 120 24 /device_icon_120.jpg image/png 48 48 24 /device_icon_48.png image/png 120 120 24 /device_icon_120.png urn:schemas-upnp-org:service:RenderingControl:1 urn:upnp-org:serviceId:RenderingControl /upnp/control/RenderingControl1 /upnp/event/RenderingControl1 /RenderingControl_1.xml urn:schemas-upnp-org:service:ConnectionManager:1 urn:upnp-org:serviceId:ConnectionManager /upnp/control/ConnectionManager1 /upnp/event/ConnectionManager1 /ConnectionManager_1.xml urn:schemas-upnp-org:service:AVTransport:1 urn:upnp-org:serviceId:AVTransport /upnp/control/AVTransport1 /upnp/event/AVTransport1 /AVTransport_1.xml async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/device_bad_namespace.xml000066400000000000000000000046461477256211100266520ustar00rootroot00000000000000 1 0 urn:schemas-upnp-org:device:MediaRenderer:1 Dummy TV Steven Dummy TV DMR DummyTV v1 uuid:00000000-0000-0000-0000-000000000000 image/jpeg 48 48 24 /device_icon_48.jpg image/jpeg 120 120 24 /device_icon_120.jpg image/png 48 48 24 /device_icon_48.png image/png 120 120 24 /device_icon_120.png urn:schemas-upnp-org:service:RenderingControl:1 urn:upnp-org:serviceId:RenderingControl /upnp/control/RenderingControl1 /upnp/event/RenderingControl1 /RenderingControl_1.xml urn:schemas-upnp-org:service:ConnectionManager:1 urn:upnp-org:serviceId:ConnectionManager /upnp/control/ConnectionManager1 /upnp/event/ConnectionManager1 /ConnectionManager_1.xml urn:schemas-upnp-org:service:AVTransport:1 urn:upnp-org:serviceId:AVTransport /upnp/control/AVTransport1 /upnp/event/AVTransport1 /AVTransport_1.xml async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/device_embedded.xml000066400000000000000000000061161477256211100256330ustar00rootroot00000000000000 1 0 RootDevice:1 Dummy Root Device Steven Dummy TV Root device DummyRoot v1 uuid:00000000-0000-0000-0000-000000000000 image/jpeg 48 48 24 /device_icon_48.jpg image/jpeg 120 120 24 /device_icon_120.jpg image/png 48 48 24 /device_icon_48.png image/png 120 120 24 /device_icon_120.png urn:schemas-upnp-org:device:MediaRenderer:1 Dummy TV Steven Dummy TV Root device DummyTV v1 uuid:00000000-0000-0000-0000-000000000001 urn:schemas-upnp-org:service:RenderingControl:1 urn:upnp-org:serviceId:RenderingControl /upnp/control/RenderingControl1 /upnp/event/RenderingControl1 /RenderingControl_1.xml urn:schemas-upnp-org:service:ConnectionManager:1 urn:upnp-org:serviceId:ConnectionManager /upnp/control/ConnectionManager1 /upnp/event/ConnectionManager1 /ConnectionManager_1.xml urn:schemas-upnp-org:service:AVTransport:1 urn:upnp-org:serviceId:AVTransport /upnp/control/AVTransport1 /upnp/event/AVTransport1 /AVTransport_1.xml async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/device_incomplete.xml000066400000000000000000000030131477256211100262320ustar00rootroot00000000000000 1 0 urn:schemas-upnp-org:device:MediaRenderer:1 Dummy TV Steven Dummy TV DMR DummyTV v1 uuid:00000000-0000-0000-0000-000000000000 image/jpeg 48 48 24 /device_icon_48.jpg image/jpeg 120 120 24 /device_icon_120.jpg image/png 48 48 24 /device_icon_48.png image/png 120 120 24 /device_icon_120.png async_upnp_client-0.44.0/tests/fixtures/dlna/dmr/device_with_empty_descriptor.xml000066400000000000000000000056251477256211100305350ustar00rootroot00000000000000 1 0 urn:schemas-upnp-org:device:MediaRenderer:1 Dummy TV Steven Dummy TV DMR DummyTV v1 uuid:00000000-0000-0000-0000-000000000000 image/jpeg 48 48 24 /device_icon_48.jpg image/jpeg 120 120 24 /device_icon_120.jpg image/png 48 48 24 /device_icon_48.png image/png 120 120 24 /device_icon_120.png urn:schemas-upnp-org:service:RenderingControl:1 urn:upnp-org:serviceId:RenderingControl /upnp/control/RenderingControl1 /upnp/event/RenderingControl1 /RenderingControl_1.xml urn:schemas-upnp-org:service:ConnectionManager:1 urn:upnp-org:serviceId:ConnectionManager /upnp/control/ConnectionManager1 /upnp/event/ConnectionManager1 /ConnectionManager_1.xml urn:schemas-upnp-org:service:AVTransport:1 urn:upnp-org:serviceId:AVTransport /upnp/control/AVTransport1 /upnp/event/AVTransport1 /AVTransport_1.xml urn:schemas-tencent-com:service:QPlay:1 urn:tencent-com:serviceId:QPlay /QPlay /QPlay/eventSub /Empty_Descriptor.xml async_upnp_client-0.44.0/tests/fixtures/dlna/dms/000077500000000000000000000000001477256211100220365ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/fixtures/dlna/dms/ConnectionManager_1.xml000066400000000000000000000104521477256211100263740ustar00rootroot00000000000000 1 0 GetProtocolInfo Source out SourceProtocolInfo Sink out SinkProtocolInfo GetCurrentConnectionIDs ConnectionIDs out CurrentConnectionIDs GetCurrentConnectionInfo ConnectionID in A_ARG_TYPE_ConnectionID RcsID out A_ARG_TYPE_RcsID AVTransportID out A_ARG_TYPE_AVTransportID ProtocolInfo out A_ARG_TYPE_ProtocolInfo PeerConnectionManager out A_ARG_TYPE_ConnectionManager PeerConnectionID out A_ARG_TYPE_ConnectionID Direction out A_ARG_TYPE_Direction Status out A_ARG_TYPE_ConnectionStatus SourceProtocolInfo string SinkProtocolInfo string CurrentConnectionIDs string A_ARG_TYPE_ConnectionStatus string OK ContentFormatMismatch InsufficientBandwidth UnreliableChannel Unknown A_ARG_TYPE_ConnectionManager string A_ARG_TYPE_Direction string Input Output A_ARG_TYPE_ProtocolInfo string A_ARG_TYPE_ConnectionID i4 A_ARG_TYPE_AVTransportID i4 A_ARG_TYPE_RcsID i4 async_upnp_client-0.44.0/tests/fixtures/dlna/dms/ContentDirectory_1.xml000066400000000000000000000170551477256211100263070ustar00rootroot00000000000000 1 0 GetSearchCapabilities SearchCaps out SearchCapabilities GetSortCapabilities SortCaps out SortCapabilities GetSystemUpdateID Id out SystemUpdateID Browse ObjectID in A_ARG_TYPE_ObjectID BrowseFlag in A_ARG_TYPE_BrowseFlag Filter in A_ARG_TYPE_Filter StartingIndex in A_ARG_TYPE_Index RequestedCount in A_ARG_TYPE_Count SortCriteria in A_ARG_TYPE_SortCriteria Result out A_ARG_TYPE_Result NumberReturned out A_ARG_TYPE_Count TotalMatches out A_ARG_TYPE_Count UpdateID out A_ARG_TYPE_UpdateID Search ContainerID in A_ARG_TYPE_ObjectID SearchCriteria in A_ARG_TYPE_SearchCriteria Filter in A_ARG_TYPE_Filter StartingIndex in A_ARG_TYPE_Index RequestedCount in A_ARG_TYPE_Count SortCriteria in A_ARG_TYPE_SortCriteria Result out A_ARG_TYPE_Result NumberReturned out A_ARG_TYPE_Count TotalMatches out A_ARG_TYPE_Count UpdateID out A_ARG_TYPE_UpdateID UpdateObject ObjectID in A_ARG_TYPE_ObjectID CurrentTagValue in A_ARG_TYPE_TagValueList NewTagValue in A_ARG_TYPE_TagValueList TransferIDs string A_ARG_TYPE_ObjectID string A_ARG_TYPE_Result string A_ARG_TYPE_SearchCriteria string A_ARG_TYPE_BrowseFlag string BrowseMetadata BrowseDirectChildren A_ARG_TYPE_Filter string A_ARG_TYPE_SortCriteria string A_ARG_TYPE_Index ui4 A_ARG_TYPE_Count ui4 A_ARG_TYPE_UpdateID ui4 A_ARG_TYPE_TagValueList string SearchCapabilities string SortCapabilities string SystemUpdateID ui4 ContainerUpdateIDs string async_upnp_client-0.44.0/tests/fixtures/dlna/dms/action_Browse_children_0.xml000066400000000000000000000032051477256211100274450ustar00rootroot00000000000000 <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"> <container id="64" parentID="0" restricted="1" searchable="1" childCount="4"><dc:title>Browse Folders</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container><container id="1" parentID="0" restricted="1" searchable="1" childCount="7"><dc:title>Music</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container><container id="3" parentID="0" restricted="1" searchable="1" childCount="5"><dc:title>Pictures</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container><container id="2" parentID="0" restricted="1" searchable="1" childCount="3"><dc:title>Video</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container></DIDL-Lite> 4 4 2333 async_upnp_client-0.44.0/tests/fixtures/dlna/dms/action_Browse_children_2.xml000066400000000000000000000026231477256211100274520ustar00rootroot00000000000000 <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"> <container id="2$8" parentID="2" restricted="1" searchable="1" childCount="583"><dc:title>All Video</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container><container id="2$15" parentID="2" restricted="1" searchable="1" childCount="2"><dc:title>Folders</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container><container id="2$FF0" parentID="2" restricted="1" searchable="0" childCount="50"><dc:title>Recently Added</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container></DIDL-Lite> 3 3 2333 async_upnp_client-0.44.0/tests/fixtures/dlna/dms/action_Browse_children_item.xml000066400000000000000000000011701477256211100302430ustar00rootroot00000000000000 <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"> </DIDL-Lite> 0 0 2333 async_upnp_client-0.44.0/tests/fixtures/dlna/dms/action_Browse_metadata_0.xml000066400000000000000000000022031477256211100274320ustar00rootroot00000000000000 <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"> <container id="0" parentID="-1" restricted="1" searchable="1" childCount="4"><upnp:searchClass includeDerived="1">object.item.audioItem</upnp:searchClass><upnp:searchClass includeDerived="1">object.item.imageItem</upnp:searchClass><upnp:searchClass includeDerived="1">object.item.videoItem</upnp:searchClass><dc:title>root</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container></DIDL-Lite> 1 1 2333 async_upnp_client-0.44.0/tests/fixtures/dlna/dms/action_Browse_metadata_2.xml000066400000000000000000000015701477256211100274420ustar00rootroot00000000000000 <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"> <container id="2" parentID="0" restricted="1" searchable="1" childCount="3"><dc:title>Video</dc:title><upnp:class>object.container.storageFolder</upnp:class><upnp:storageUsed>-1</upnp:storageUsed></container></DIDL-Lite> 1 1 2333 async_upnp_client-0.44.0/tests/fixtures/dlna/dms/action_Browse_metadata_item.xml000066400000000000000000000030721477256211100302360ustar00rootroot00000000000000 <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"> <item id="1$6$35$1$1" parentID="1$6$35$1" restricted="1" refID="64$2$35$0$1"><dc:title>Test song</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><dc:creator>Test creator</dc:creator><dc:date>1901-01-01</dc:date><upnp:artist>Test artist</upnp:artist><upnp:album>Test album</upnp:album><upnp:genre>Rock &amp; Roll</upnp:genre><upnp:originalTrackNumber>2</upnp:originalTrackNumber><res size="2905191" duration="0:02:00.938" bitrate="192000" sampleFrequency="44100" nrAudioChannels="2" protocolInfo="http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000">http://dlna_dms:1234/media/2483.mp3</res><upnp:albumArtURI dlna:profileID="JPEG_TN" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">http://dlna_dms:1234/art/238-2483.jpg</upnp:albumArtURI></item></DIDL-Lite> 1 1 2333 async_upnp_client-0.44.0/tests/fixtures/dlna/dms/device.xml000066400000000000000000000041671477256211100240270ustar00rootroot00000000000000 1 0 urn:schemas-upnp-org:device:MediaServer:1 Dummy server Steven Dummy media server Dummy Server 1.3.0 uuid:11111111-0000-0000-0000-000000000000 DMS-1.50 / image/png 48 48 24 /icons/sm.png image/png 120 120 24 /icons/lrg.png image/jpeg 48 48 24 /icons/sm.jpg image/jpeg 120 120 24 /icons/lrg.jpg urn:schemas-upnp-org:service:ContentDirectory:1 urn:upnp-org:serviceId:ContentDirectory /upnp/control/ContentDir /upnp/event/ContentDir /ContentDirectory_1.xml urn:schemas-upnp-org:service:ConnectionManager:1 urn:upnp-org:serviceId:ConnectionManager /upnp/control/ConnectionMgr /upnp/event/ConnectionMgr /ConnectionManager_1.xml async_upnp_client-0.44.0/tests/fixtures/igd/000077500000000000000000000000001477256211100211005ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/fixtures/igd/Layer3Forwarding.xml000066400000000000000000000015601477256211100250060ustar00rootroot00000000000000 1 0 SetDefaultConnectionService NewDefaultConnectionService in DefaultConnectionService GetDefaultConnectionService NewDefaultConnectionService out DefaultConnectionService DefaultConnectionService string async_upnp_client-0.44.0/tests/fixtures/igd/WANCommonInterfaceConfig.xml000066400000000000000000000073741477256211100264020ustar00rootroot00000000000000 1 0 SetEnabledForInternet NewEnabledForInternet in EnabledForInternet GetEnabledForInternet NewEnabledForInternet out EnabledForInternet GetCommonLinkProperties NewWANAccessType out WANAccessType NewLayer1UpstreamMaxBitRate out Layer1UpstreamMaxBitRate NewLayer1DownstreamMaxBitRate out Layer1DownstreamMaxBitRate NewPhysicalLinkStatus out PhysicalLinkStatus GetTotalBytesSent NewTotalBytesSent out TotalBytesSent GetTotalBytesReceived NewTotalBytesReceived out TotalBytesReceived GetTotalPacketsSent NewTotalPacketsSent out TotalPacketsSent GetTotalPacketsReceived NewTotalPacketsReceived out TotalPacketsReceived WANAccessType string DSL POTS Cable Ethernet Other Layer1UpstreamMaxBitRate ui4 Layer1DownstreamMaxBitRate ui4 PhysicalLinkStatus string Up Down Initializing Unavailable TotalBytesSent ui4 TotalBytesReceived ui4 TotalPacketsSent ui4 TotalPacketsReceived ui4 EnabledForInternet boolean async_upnp_client-0.44.0/tests/fixtures/igd/WANIPConnection.xml000066400000000000000000000214451477256211100245260ustar00rootroot00000000000000 1 0 SetConnectionType NewConnectionType in ConnectionType GetConnectionTypeInfo NewConnectionType out ConnectionType NewPossibleConnectionTypes out PossibleConnectionTypes RequestConnection ForceTermination GetStatusInfo NewConnectionStatus out ConnectionStatus NewLastConnectionError out LastConnectionError NewUptime out Uptime GetNATRSIPStatus NewRSIPAvailable out RSIPAvailable NewNATEnabled out NATEnabled GetGenericPortMappingEntry NewPortMappingIndex in PortMappingNumberOfEntries NewRemoteHost out RemoteHost NewExternalPort out ExternalPort NewProtocol out PortMappingProtocol NewInternalPort out InternalPort NewInternalClient out InternalClient NewEnabled out PortMappingEnabled NewPortMappingDescription out PortMappingDescription NewLeaseDuration out PortMappingLeaseDuration GetSpecificPortMappingEntry NewRemoteHost in RemoteHost NewExternalPort in ExternalPort NewProtocol in PortMappingProtocol NewInternalPort out InternalPort NewInternalClient out InternalClient NewEnabled out PortMappingEnabled NewPortMappingDescription out PortMappingDescription NewLeaseDuration out PortMappingLeaseDuration AddPortMapping NewRemoteHost in RemoteHost NewExternalPort in ExternalPort NewProtocol in PortMappingProtocol NewInternalPort in InternalPort NewInternalClient in InternalClient NewEnabled in PortMappingEnabled NewPortMappingDescription in PortMappingDescription NewLeaseDuration in PortMappingLeaseDuration DeletePortMapping NewRemoteHost in RemoteHost NewExternalPort in ExternalPort NewProtocol in PortMappingProtocol GetExternalIPAddress NewExternalIPAddress out ExternalIPAddress ConnectionType string PossibleConnectionTypes string Unconfigured IP_Routed IP_Bridged ConnectionStatus string Unconfigured Connected Disconnected Uptime ui4 LastConnectionError string ERROR_NONE ERROR_UNKNOWN RSIPAvailable boolean NATEnabled boolean ExternalIPAddress string PortMappingNumberOfEntries ui2 PortMappingEnabled boolean PortMappingLeaseDuration ui4 RemoteHost string ExternalPort ui2 InternalPort ui2 PortMappingProtocol string TCP UDP InternalClient string PortMappingDescription string async_upnp_client-0.44.0/tests/fixtures/igd/action_WANCIC_GetTotalBytesReceived.xml000066400000000000000000000006471477256211100304130ustar00rootroot00000000000000 1337 async_upnp_client-0.44.0/tests/fixtures/igd/action_WANCIC_GetTotalBytesReceived_i4.xml000066400000000000000000000006551477256211100310060ustar00rootroot00000000000000 -531985522 async_upnp_client-0.44.0/tests/fixtures/igd/action_WANCIC_GetTotalPacketsReceived.xml000066400000000000000000000006671477256211100307210ustar00rootroot00000000000000 async_upnp_client-0.44.0/tests/fixtures/igd/action_WANIPConnection_GetStatusInfoInvalidUptime.xml000066400000000000000000000010051477256211100333630ustar00rootroot00000000000000 Connected ERROR_NONE 0 Days, 01:00:00 async_upnp_client-0.44.0/tests/fixtures/igd/action_WANPIPConnection_DeletePortMapping.xml000066400000000000000000000005501477256211100316400ustar00rootroot00000000000000 async_upnp_client-0.44.0/tests/fixtures/igd/device.xml000066400000000000000000000047411477256211100230670ustar00rootroot00000000000000 1 0 urn:schemas-upnp-org:device:InternetGatewayDevice:1 Dummy Router Steven Dummy Router IGD DummyRouter v1 uuid:00000000-0000-0000-0000-000000000000 urn:schemas-upnp-org:service:Layer3Forwarding:1 urn:upnp-org:serviceId:L3Forwarding1 /Layer3Forwarding.xml /Layer3Forwarding /Layer3Forwarding urn:schemas-upnp-org:device:WANDevice:1 WANDevice Steven WANDevice uuid:00000000-0000-0000-0000-000000000001 urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 urn:upnp-org:serviceId:WANCommonIFC1 /WANCommonInterfaceConfig.xml /WANCommonInterfaceConfig /WANCommonInterfaceConfig urn:schemas-upnp-org:device:WANConnectionDevice:1 WANConnectionDevice Steven WANConnectionDevice uuid:00000000-0000-0000-0000-000000000002 urn:schemas-upnp-org:service:WANIPConnection:1 urn:upnp-org:serviceId:WANIPConn1 /WANIPConnection.xml /WANIPConnection /WANIPConnection async_upnp_client-0.44.0/tests/fixtures/scpd_i8.xml000066400000000000000000000017601477256211100224140ustar00rootroot00000000000000 1 0 X_BigIntegers SignedEight in A_ARG_TYPE_I UnsignedEight in A_ARG_TYPE_UI A_ARG_TYPE_I i8 A_ARG_TYPE_UI ui8 async_upnp_client-0.44.0/tests/fixtures/server/000077500000000000000000000000001477256211100216435ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/fixtures/server/action_request.xml000066400000000000000000000004621477256211100254140ustar00rootroot00000000000000 foo async_upnp_client-0.44.0/tests/fixtures/server/action_response.xml000066400000000000000000000006131477256211100255600ustar00rootroot00000000000000 foo0 async_upnp_client-0.44.0/tests/fixtures/server/device.xml000066400000000000000000000017171477256211100236320ustar00rootroot00000000000000 10:urn:schemas-upnp-org:device:TestServerDevice:1Test ServerTestTest ServerTestServerv0.0.10000001uuid:adca2e25-cbe4-427a-a5c3-9b5931e7b79burn:schemas-upnp-org:service:TestServerService:1urn:upnp-org:serviceId:TestServerService/upnp/control/TestServerService/upnp/event/TestServerService/ContentDirectory.xml async_upnp_client-0.44.0/tests/fixtures/server/subscribe_response_0.xml000066400000000000000000000003011477256211100264750ustar00rootroot00000000000000 0 async_upnp_client-0.44.0/tests/fixtures/server/subscribe_response_1.xml000066400000000000000000000003011477256211100264760ustar00rootroot00000000000000 1 async_upnp_client-0.44.0/tests/profiles/000077500000000000000000000000001477256211100203075ustar00rootroot00000000000000async_upnp_client-0.44.0/tests/profiles/__init__.py000066400000000000000000000000671477256211100224230ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for profiles.""" async_upnp_client-0.44.0/tests/profiles/test_dlna_dmr.py000066400000000000000000000643741477256211100235160ustar00rootroot00000000000000"""Unit tests for the DLNA DMR profile.""" import asyncio import time from typing import List, Sequence from unittest import mock import defusedxml.ElementTree import pytest from didl_lite import didl_lite from async_upnp_client.client import UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import HttpRequest, HttpResponse from async_upnp_client.profiles.dlna import ( DmrDevice, _parse_last_change_event, dlna_handle_notify_last_change, split_commas, ) from ..conftest import RESPONSE_MAP, UpnpTestNotifyServer, UpnpTestRequester, read_file AVT_NOTIFY_HEADERS = { "NT": "upnp:event", "NTS": "upnp:propchange", "SID": "uuid:dummy-avt1", } AVT_CURRENT_TRANSPORT_ACTIONS_NOTIFY_BODY_FMT = """ <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/"> <InstanceID val="0"> <CurrentTransportActions val="{actions}"/> </InstanceID> </Event> """ def assert_xml_equal( left: defusedxml.ElementTree, right: defusedxml.ElementTree ) -> None: """Check two XML trees are equal.""" assert left.tag == right.tag assert left.text == right.text assert left.tail == right.tail assert left.attrib == right.attrib assert len(left) == len(right) for left_child, right_child in zip(left, right): assert_xml_equal(left_child, right_child) def test_parse_last_change_event() -> None: """Test parsing a last change event.""" data = """ """ assert _parse_last_change_event(data) == { "0": {"TransportState": "PAUSED_PLAYBACK"} } def test_parse_last_change_event_multiple_instances() -> None: """Test parsing a last change event with multiple instance.""" data = """ """ assert _parse_last_change_event(data) == { "0": {"TransportState": "PAUSED_PLAYBACK"}, "1": {"TransportState": "PLAYING"}, } def test_parse_last_change_event_multiple_channels() -> None: """Test parsing a last change event with multiple channels.""" data = """ """ assert _parse_last_change_event(data) == { "0": {"Volume": "10"}, } def test_parse_last_change_event_invalid_xml() -> None: """Test parsing an invalid (non valid XML) last change event.""" data = """ """ assert _parse_last_change_event(data) == { "0": {"TransportState": "PAUSED_PLAYBACK"} } @pytest.mark.parametrize( "value, expected", ( ("", []), (",", []), (", ,", []), ( "http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-m4a:*", [ "http-get:*:audio/mp3:*", "http-get:*:audio/mp4:*", "http-get:*:audio/x-m4a:*", ], ), ( "http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-m4a:*,", [ "http-get:*:audio/mp3:*", "http-get:*:audio/mp4:*", "http-get:*:audio/x-m4a:*", ], ), ), ) def test_split_commas(value: str, expected: List[str]) -> None: """Test splitting comma separated value lists.""" actual = split_commas(value) assert actual == expected @pytest.mark.asyncio async def test_on_notify_dlna_event() -> None: """Test handling an event..""" changed_vars: List[UpnpStateVariable] = [] def on_event( _self: UpnpService, changed_state_variables: Sequence[UpnpStateVariable] ) -> None: nonlocal changed_vars changed_vars += changed_state_variables assert changed_state_variables if changed_state_variables[0].name == "LastChange": last_change = changed_state_variables[0] assert last_change.name == "LastChange" dlna_handle_notify_last_change(last_change) requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") service.on_event = on_event notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler await event_handler.async_subscribe(service) headers = { "NT": "upnp:event", "NTS": "upnp:propchange", "SID": "uuid:dummy", } body = """ <Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"> <InstanceID val="0"> <Mute channel="Master" val="0"/> <Volume channel="Master" val="50"/> </InstanceID> </Event> """ http_request = HttpRequest( "NOTIFY", "http://dlna_dmr:1234/upnp/event/RenderingControl1", headers, body ) result = await event_handler.handle_notify(http_request) assert result == 200 assert len(changed_vars) == 3 state_var = service.state_variable("Volume") assert state_var.value == 50 @pytest.mark.asyncio async def test_wait_for_can_play_evented() -> None: """Test async_wait_for_can_play with a variable change event.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) await profile.async_subscribe_services() # Send a NOTIFY of CurrentTransportActions without Play http_request = HttpRequest( "NOTIFY", "http://dlna_dmr:1234/upnp/event/AVTransport1", AVT_NOTIFY_HEADERS, AVT_CURRENT_TRANSPORT_ACTIONS_NOTIFY_BODY_FMT.format(actions="Stop"), ) result = await event_handler.handle_notify(http_request) assert result == 200 # Should not be able to play yet assert not profile.can_play # Trigger variable change event in 0.1 seconds, less than the sleep time of # the wait loop async def delayed_notify() -> None: await asyncio.sleep(0.1) # Send NOTIFY of change to CurrentTransportActions http_request = HttpRequest( "NOTIFY", "http://dlna_dmr:1234/upnp/event/AVTransport1", AVT_NOTIFY_HEADERS, AVT_CURRENT_TRANSPORT_ACTIONS_NOTIFY_BODY_FMT.format(actions="Pause,Play"), ) result = await event_handler.handle_notify(http_request) assert result == 200 loop = asyncio.get_event_loop() notify_task = loop.create_task(delayed_notify()) assert notify_task # Call async_wait_for_can_play and check it returned shortly after notification started = time.monotonic() await profile.async_wait_for_can_play() waited_time = time.monotonic() - started assert 0.1 <= waited_time <= 0.5 assert profile.can_play await profile.async_unsubscribe_services() @pytest.mark.asyncio async def test_wait_for_can_play_polled() -> None: """Test async_wait_for_can_play polling state variables.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") profile = DmrDevice(device, event_handler=None) # Polling of CurrentTransportActions does not contain "Play" yet requester.response_map[ ("POST", "http://dlna_dmr:1234/upnp/control/AVTransport1") ] = HttpResponse( 200, {}, read_file("dlna/dmr/action_GetCurrentTransportActions_Stop.xml"), ) # Force update of CurrentTransportActions # pylint: disable=protected-access await profile._async_poll_state_variables( "AVT", ["GetCurrentTransportActions"], InstanceID=0 ) # Should not be able to play yet assert not profile.can_play # Polling of CurrentTransportActions now contains "Play" requester.response_map[ ("POST", "http://dlna_dmr:1234/upnp/control/AVTransport1") ] = HttpResponse( 200, {}, read_file("dlna/dmr/action_GetCurrentTransportActions_PlaySeek.xml"), ) # Call async_wait_for_can_play and check it returned after polling started = time.monotonic() await profile.async_wait_for_can_play() waited_time = time.monotonic() - started assert 0.1 <= waited_time <= 1.0 assert profile.can_play @pytest.mark.asyncio async def test_wait_for_can_play_timeout() -> None: """Test async_wait_for_can_play times out waiting for ability to play.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") profile = DmrDevice(device, event_handler=None) # Polling of CurrentTransportActions does not contain "Play" yet requester.response_map[ ("POST", "http://dlna_dmr:1234/upnp/control/AVTransport1") ] = HttpResponse( 200, {}, read_file("dlna/dmr/action_GetCurrentTransportActions_Stop.xml"), ) # Force update of CurrentTransportActions # pylint: disable=protected-access await profile._async_poll_state_variables( "AVT", ["GetCurrentTransportActions"], InstanceID=0 ) # Should not be able to play assert not profile.can_play # Call async_wait_for_can_play with a shorter timeout (to not delay tests too long) started = time.monotonic() await profile.async_wait_for_can_play(max_wait_time=0.5) waited_time = time.monotonic() - started assert 0.5 <= waited_time <= 1.5 assert not profile.can_play @pytest.mark.asyncio async def test_fetch_headers() -> None: """Test _fetch_headers when the server supports HEAD, GET with range, or just GET.""" # pylint: disable=protected-access requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") profile = DmrDevice(device, event_handler=None) media_url = "http://dlna_dms:4321/object/file_1222" fetch_headers = {"GetContentFeatures.dlna.org": "1"} expected_response_headers = {"Content-Length": "1024", "Content-Type": "audio/mpeg"} # When HEAD works with mock.patch.object( profile.profile_device.requester, "async_http_request" ) as ahr_mock: ahr_mock.side_effect = [HttpResponse(200, expected_response_headers, "")] headers = await profile._fetch_headers(media_url, fetch_headers) ahr_mock.assert_awaited_once_with( HttpRequest("HEAD", media_url, fetch_headers, None) ) assert headers == expected_response_headers # HEAD method is not allowed, but GET with Range works with mock.patch.object( profile.profile_device.requester, "async_http_request" ) as ahr_mock: ranged_response_headers = dict(expected_response_headers) ranged_response_headers["Content-Range"] = "bytes 0-0/1024" ahr_mock.side_effect = [ HttpResponse(405, expected_response_headers, ""), HttpResponse(200, ranged_response_headers, ""), ] headers = await profile._fetch_headers(media_url, fetch_headers) assert ahr_mock.await_args_list == [ mock.call(HttpRequest("HEAD", media_url, fetch_headers, None)), mock.call( HttpRequest( "GET", media_url, dict(fetch_headers, Range="bytes=0-0"), None ) ), ] assert headers == ranged_response_headers # HEAD method and GET with Range is not allowed, but plain GET works with mock.patch.object( profile.profile_device.requester, "async_http_request" ) as ahr_mock: # Different headers for working response, to check correct thing returned get_headers = dict(expected_response_headers) get_headers["Content-Length"] = "2" ahr_mock.side_effect = [ HttpResponse(405, expected_response_headers, ""), HttpResponse(405, expected_response_headers, ""), HttpResponse(200, get_headers, ""), ] headers = await profile._fetch_headers(media_url, fetch_headers) assert ahr_mock.await_args_list == [ mock.call(HttpRequest("HEAD", media_url, fetch_headers, None)), mock.call( HttpRequest( "GET", media_url, dict(fetch_headers, Range="bytes=0-0"), None ) ), mock.call(HttpRequest("GET", media_url, fetch_headers, None)), ] assert headers == get_headers # HTTP 404 should bail early with mock.patch.object( profile.profile_device.requester, "async_http_request" ) as ahr_mock: ahr_mock.side_effect = [ HttpResponse(404, expected_response_headers, ""), HttpResponse(405, expected_response_headers, ""), HttpResponse(200, expected_response_headers, ""), ] headers = await profile._fetch_headers(media_url, fetch_headers) ahr_mock.assert_called_once_with( HttpRequest("HEAD", media_url, fetch_headers, None) ) assert headers is None # Repeated server failures should give no headers with mock.patch.object( profile.profile_device.requester, "async_http_request" ) as ahr_mock: # Different headers for working response, to check correct thing returned ahr_mock.return_value = HttpResponse(500, {}, "") headers = await profile._fetch_headers(media_url, fetch_headers) assert ahr_mock.await_args_list == [ mock.call(HttpRequest("HEAD", media_url, fetch_headers, None)), mock.call( HttpRequest( "GET", media_url, dict(fetch_headers, Range="bytes=0-0"), None ) ), mock.call(HttpRequest("GET", media_url, fetch_headers, None)), ] assert headers is None @pytest.mark.asyncio async def test_construct_play_media_metadata_types() -> None: """Test various MIME and UPnP type options for construct_play_media_metadata.""" # pylint: disable=too-many-statements requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) media_url = "http://dlna_dms:4321/object/file_1222" media_title = "Test music" # No server to supply DLNA headers metadata_xml = await profile.construct_play_media_metadata(media_url, media_title) # Sanity check that didl_lite is giving expected XML expected_xml = defusedxml.ElementTree.fromstring( """ Test music object.item http://dlna_dms:4321/object/file_1222 """.replace( "\n", "" ) ) assert_xml_equal(defusedxml.ElementTree.fromstring(metadata_xml), expected_xml) metadata = didl_lite.from_xml_string(metadata_xml)[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item" assert metadata.res assert metadata.res is metadata.resources assert metadata.res[0].uri == media_url assert metadata.res[0].protocol_info == "http-get:*:application/octet-stream:*" metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata(media_url + ".mp3", media_title) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.audioItem" assert metadata.res[0].uri == media_url + ".mp3" assert metadata.res[0].protocol_info == "http-get:*:audio/mpeg:*" metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, default_mime_type="video/test-mime" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url assert metadata.res[0].protocol_info == "http-get:*:video/test-mime:*" metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, default_upnp_class="object.item.imageItem" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.imageItem" assert metadata.res[0].uri == media_url assert metadata.res[0].protocol_info == "http-get:*:application/octet-stream:*" metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_mime_type="video/test-mime" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url assert metadata.res[0].protocol_info == "http-get:*:video/test-mime:*" metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_upnp_class="object.item.imageItem" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.imageItem" assert metadata.res[0].uri == media_url assert metadata.res[0].protocol_info == "http-get:*:application/octet-stream:*" metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_dlna_features="DLNA_OVERRIDE_FEATURES", ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:application/octet-stream:DLNA_OVERRIDE_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_mime_type="video/test-mime", override_dlna_features="DLNA_OVERRIDE_FEATURES", ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:video/test-mime:DLNA_OVERRIDE_FEATURES" ) # Media server supplies media information for HEAD requests requester.response_map[("HEAD", media_url)] = HttpResponse( 200, { "ContentFeatures.dlna.org": "DLNA_SERVER_FEATURES", "Content-Type": "video/server-mime", }, "", ) requester.response_map[("HEAD", media_url + ".mp3")] = HttpResponse( 200, { "ContentFeatures.dlna.org": "DLNA_SERVER_FEATURES", "Content-Type": "video/server-mime", }, "", ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata(media_url, media_title) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:video/server-mime:DLNA_SERVER_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata(media_url + ".mp3", media_title) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url + ".mp3" assert ( metadata.res[0].protocol_info == "http-get:*:video/server-mime:DLNA_SERVER_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, default_mime_type="video/test-mime" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:video/server-mime:DLNA_SERVER_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, default_upnp_class="object.item.imageItem" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:video/server-mime:DLNA_SERVER_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_mime_type="image/test-mime" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.imageItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:image/test-mime:DLNA_SERVER_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_upnp_class="object.item.imageItem" ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.imageItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:video/server-mime:DLNA_SERVER_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_dlna_features="DLNA_OVERRIDE_FEATURES", ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.videoItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:video/server-mime:DLNA_OVERRIDE_FEATURES" ) metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_mime_type="image/test-mime", override_dlna_features="DLNA_OVERRIDE_FEATURES", ) )[0] assert metadata.title == media_title assert metadata.upnp_class == "object.item.imageItem" assert metadata.res[0].uri == media_url assert ( metadata.res[0].protocol_info == "http-get:*:image/test-mime:DLNA_OVERRIDE_FEATURES" ) @pytest.mark.asyncio async def test_construct_play_media_metadata_meta_data() -> None: """Test meta_data values for construct_play_media_metadata.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) media_url = "http://dlna_dms:4321/object/file_1222.mp3" media_title = "Test music" meta_data = { "title": "Test override title", # Should override media_title parameter "description": "Short test description", # In base audioItem class "artist": "Test singer", "album": "Test album", "originalTrackNumber": 3, # Should be converted to lower_camel_case } # No server information about media type or contents # Without specifying UPnP class, only generic types lacking certain values are used metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, meta_data=meta_data, ) )[0] assert metadata.upnp_class == "object.item.audioItem" assert metadata.title == "Test override title" assert metadata.description == "Short test description" assert not hasattr(metadata, "artist") assert not hasattr(metadata, "album") assert not hasattr(metadata, "original_track_number") assert metadata.res[0].uri == media_url assert metadata.res[0].protocol_info == "http-get:*:audio/mpeg:*" # Set the UPnP class correctly metadata = didl_lite.from_xml_string( await profile.construct_play_media_metadata( media_url, media_title, override_upnp_class="object.item.audioItem.musicTrack", meta_data=meta_data, ) )[0] assert metadata.upnp_class == "object.item.audioItem.musicTrack" assert metadata.title == "Test override title" assert metadata.description == "Short test description" assert metadata.artist == "Test singer" assert metadata.album == "Test album" assert metadata.original_track_number == "3" assert metadata.res[0].uri == media_url assert metadata.res[0].protocol_info == "http-get:*:audio/mpeg:*" async_upnp_client-0.44.0/tests/profiles/test_dlna_dms.py000066400000000000000000000145151477256211100235070ustar00rootroot00000000000000"""Unit tests for the DLNA DMS profile.""" import pytest from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import HttpResponse from async_upnp_client.exceptions import UpnpResponseError from async_upnp_client.profiles.dlna import DmsDevice from ..conftest import RESPONSE_MAP, UpnpTestNotifyServer, UpnpTestRequester, read_file @pytest.mark.asyncio async def test_async_browse_metadata() -> None: """Test retrieving object metadata.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dms:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmsDevice(device, event_handler=event_handler) # Object 0 is the root and must always exist requester.response_map[("POST", "http://dlna_dms:1234/upnp/control/ContentDir")] = ( HttpResponse( 200, {}, read_file("dlna/dms/action_Browse_metadata_0.xml"), ) ) metadata = await profile.async_browse_metadata("0") assert metadata.parent_id == "-1" assert metadata.id == "0" assert metadata.title == "root" assert metadata.upnp_class == "object.container.storageFolder" assert metadata.child_count == "4" # Object 2 will give some different results requester.response_map[("POST", "http://dlna_dms:1234/upnp/control/ContentDir")] = ( HttpResponse( 200, {}, read_file("dlna/dms/action_Browse_metadata_2.xml"), ) ) metadata = await profile.async_browse_metadata("2") assert metadata.parent_id == "0" assert metadata.id == "2" assert metadata.title == "Video" assert metadata.upnp_class == "object.container.storageFolder" assert metadata.child_count == "3" # Object that is an item and not a container requester.response_map[("POST", "http://dlna_dms:1234/upnp/control/ContentDir")] = ( HttpResponse( 200, {}, read_file("dlna/dms/action_Browse_metadata_item.xml"), ) ) metadata = await profile.async_browse_metadata("1$6$35$1$1") assert metadata.parent_id == "1$6$35$1" assert metadata.id == "1$6$35$1$1" assert metadata.title == "Test song" assert metadata.upnp_class == "object.item.audioItem.musicTrack" assert metadata.artist == "Test artist" assert metadata.genre == "Rock & Roll" assert len(metadata.resources) == 1 assert metadata.resources[0].uri == "http://dlna_dms:1234/media/2483.mp3" assert ( metadata.resources[0].protocol_info == "http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_CI=0;" "DLNA.ORG_FLAGS=01700000000000000000000000000000" ) assert metadata.resources[0].size == "2905191" assert metadata.resources[0].duration == "0:02:00.938" # Bad object ID should result in a UpnpError (HTTP 701: No such object) requester.exceptions.append(UpnpResponseError(status=701)) with pytest.raises(UpnpResponseError) as err: await profile.async_browse_metadata("no object") assert err.value.status == 701 @pytest.mark.asyncio async def test_async_browse_children() -> None: """Test retrieving children of a container.""" # pylint: disable=too-many-statements requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dms:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmsDevice(device, event_handler=event_handler) # Object 0 is the root and must always exist requester.response_map[("POST", "http://dlna_dms:1234/upnp/control/ContentDir")] = ( HttpResponse( 200, {}, read_file("dlna/dms/action_Browse_children_0.xml"), ) ) result = await profile.async_browse_direct_children("0") assert result.number_returned == 4 assert result.total_matches == 4 assert result.update_id == 2333 children = result.result assert len(children) == 4 assert children[0].title == "Browse Folders" assert children[0].id == "64" assert children[0].child_count == "4" assert children[1].title == "Music" assert children[1].id == "1" assert children[1].child_count == "7" assert children[2].title == "Pictures" assert children[2].id == "3" assert children[2].child_count == "5" assert children[3].title == "Video" assert children[3].id == "2" assert children[3].child_count == "3" # Object 2 will give some different results requester.response_map[("POST", "http://dlna_dms:1234/upnp/control/ContentDir")] = ( HttpResponse( 200, {}, read_file("dlna/dms/action_Browse_children_2.xml"), ) ) result = await profile.async_browse_direct_children("2") assert result.number_returned == 3 assert result.total_matches == 3 assert result.update_id == 2333 children = result.result assert len(children) == 3 assert children[0].title == "All Video" assert children[0].id == "2$8" assert children[0].child_count == "583" assert children[1].title == "Folders" assert children[1].id == "2$15" assert children[1].child_count == "2" assert children[2].title == "Recently Added" assert children[2].id == "2$FF0" assert children[2].child_count == "50" # Object that is an item and not a container requester.response_map[("POST", "http://dlna_dms:1234/upnp/control/ContentDir")] = ( HttpResponse( 200, {}, read_file("dlna/dms/action_Browse_children_item.xml"), ) ) result = await profile.async_browse_direct_children("1$6$35$1$1") assert result.number_returned == 0 assert result.total_matches == 0 assert result.update_id == 2333 assert result.result == [] # Bad object ID should result in a UpnpError (HTTP 701: No such object) requester.exceptions.append(UpnpResponseError(status=701)) with pytest.raises(UpnpResponseError) as err: await profile.async_browse_direct_children("no object") assert err.value.status == 701 async_upnp_client-0.44.0/tests/profiles/test_igd.py000066400000000000000000000104651477256211100224710ustar00rootroot00000000000000"""Unit tests for the IGD profile.""" import pytest from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import HttpResponse from async_upnp_client.profiles.igd import IgdDevice from ..conftest import RESPONSE_MAP, UpnpTestNotifyServer, UpnpTestRequester, read_file @pytest.mark.asyncio async def test_init_igd_profile() -> None: """Test if a IGD device can be initialized.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://igd:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = IgdDevice(device, event_handler=event_handler) assert profile @pytest.mark.asyncio async def test_get_total_bytes_received() -> None: """Test getting total bytes received.""" responses = dict(RESPONSE_MAP) responses[("POST", "http://igd:1234/WANCommonInterfaceConfig")] = HttpResponse( 200, {}, read_file("igd/action_WANCIC_GetTotalBytesReceived.xml"), ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://igd:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = IgdDevice(device, event_handler=event_handler) total_bytes_received = await profile.async_get_total_bytes_received() assert total_bytes_received == 1337 @pytest.mark.asyncio async def test_get_total_packets_received_empty_response() -> None: """Test getting total packets received with empty response, for broken (Draytek) device.""" responses = dict(RESPONSE_MAP) responses[("POST", "http://igd:1234/WANCommonInterfaceConfig")] = HttpResponse( 200, {}, read_file("igd/action_WANCIC_GetTotalPacketsReceived.xml"), ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://igd:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = IgdDevice(device, event_handler=event_handler) total_bytes_received = await profile.async_get_total_packets_received() assert total_bytes_received is None @pytest.mark.asyncio async def test_get_status_info_invalid_uptime() -> None: """Test getting status info with an invalid uptime response.""" responses = dict(RESPONSE_MAP) responses[("POST", "http://igd:1234/WANIPConnection")] = HttpResponse( 200, {}, read_file("igd/action_WANIPConnection_GetStatusInfoInvalidUptime.xml"), ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://igd:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = IgdDevice(device, event_handler=event_handler) status_info = await profile.async_get_status_info() assert status_info is None @pytest.mark.asyncio async def test_negative_bytes_received_counter() -> None: """ Test getting a negative total bytes received counter. Some devices implement the counter as a signed integer (i4), which can result in negative values. """ responses = dict(RESPONSE_MAP) responses[("POST", "http://igd:1234/WANCommonInterfaceConfig")] = HttpResponse( 200, {}, read_file("igd/action_WANCIC_GetTotalBytesReceived_i4.xml"), ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://igd:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = IgdDevice(device, event_handler=event_handler) total_bytes_received = await profile.async_get_total_bytes_received() assert total_bytes_received == 1615498126 # 2**31 + -531985522 async_upnp_client-0.44.0/tests/profiles/test_profile.py000066400000000000000000000564711477256211100233750ustar00rootroot00000000000000"""Unit tests for profile.""" # pylint: disable=protected-access import asyncio import time from datetime import timedelta from unittest.mock import Mock import pytest from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import HttpResponse from async_upnp_client.exceptions import ( UpnpActionResponseError, UpnpCommunicationError, UpnpConnectionError, UpnpResponseError, ) from async_upnp_client.profiles.dlna import DmrDevice from async_upnp_client.profiles.igd import IgdDevice from ..conftest import RESPONSE_MAP, UpnpTestNotifyServer, UpnpTestRequester, read_file class TestUpnpProfileDevice: """Test UPnpProfileDevice.""" @pytest.mark.asyncio async def test_action_exists(self) -> None: """Test getting existing action.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) # doesn't error assert profile._action("RC", "GetMute") is not None @pytest.mark.asyncio async def test_action_not_exists(self) -> None: """Test getting non-existing action.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) # doesn't error assert profile._action("RC", "NonExisting") is None @pytest.mark.asyncio async def test_icon(self) -> None: """Test getting an icon returns the best available.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) assert profile.icon == "http://dlna_dmr:1234/device_icon_120.png" @pytest.mark.asyncio async def test_is_profile_device(self) -> None: """Test is_profile_device works for root and embedded devices.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") embedded = await factory.async_create_device( "http://dlna_dmr:1234/device_embedded.xml" ) no_services = await factory.async_create_device( "http://dlna_dmr:1234/device_incomplete.xml" ) igd_device = await factory.async_create_device("http://igd:1234/device.xml") assert DmrDevice.is_profile_device(device) is True assert DmrDevice.is_profile_device(embedded) is True assert DmrDevice.is_profile_device(no_services) is False assert DmrDevice.is_profile_device(igd_device) is False assert IgdDevice.is_profile_device(device) is False assert IgdDevice.is_profile_device(embedded) is False assert IgdDevice.is_profile_device(no_services) is False assert IgdDevice.is_profile_device(igd_device) is True @pytest.mark.asyncio async def test_is_profile_device_non_strict(self) -> None: """Test is_profile_device works for root and embedded devices.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") embedded = await factory.async_create_device( "http://dlna_dmr:1234/device_embedded.xml" ) no_services = await factory.async_create_device( "http://dlna_dmr:1234/device_incomplete.xml" ) empty_descriptor = await factory.async_create_device( "http://dlna_dmr:1234/device_with_empty_descriptor.xml" ) igd_device = await factory.async_create_device("http://igd:1234/device.xml") assert DmrDevice.is_profile_device(device) is True assert DmrDevice.is_profile_device(embedded) is True assert DmrDevice.is_profile_device(no_services) is False assert DmrDevice.is_profile_device(igd_device) is False assert IgdDevice.is_profile_device(device) is False assert IgdDevice.is_profile_device(embedded) is False assert IgdDevice.is_profile_device(no_services) is False assert IgdDevice.is_profile_device(empty_descriptor) is False assert IgdDevice.is_profile_device(igd_device) is True @pytest.mark.asyncio async def test_subscribe_manual_resubscribe(self) -> None: """Test subscribing, resub, unsub, without auto_resubscribe.""" now = time.monotonic() requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) # Test subscription timeout = await profile.async_subscribe_services(auto_resubscribe=False) assert timeout is not None # Timeout incorporates time tolerance, and is minimal renewal time assert timedelta(seconds=149 - 60) <= timeout <= timedelta(seconds=151 - 60) assert set(profile._subscriptions.keys()) == { "uuid:dummy-avt1", "uuid:dummy-cm1", "uuid:dummy", } # 3 timeouts, ~ 150, ~ 175, and ~ 300 seconds timeouts = sorted(profile._subscriptions.values()) assert timeouts[0] == pytest.approx(now + 150, abs=1) assert timeouts[1] == pytest.approx(now + 175, abs=1) assert timeouts[2] == pytest.approx(now + 300, abs=1) # Tweak timeouts to check resubscription did something entry = requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1") ] requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1") ] = HttpResponse( entry.status_code, {**entry.headers, "timeout": "Second-90"}, entry.body ) # Check subscriptions again, now timeouts should have changed timeout = await profile.async_subscribe_services(auto_resubscribe=False) assert timeout is not None assert timedelta(seconds=89 - 60) <= timeout <= timedelta(seconds=91 - 60) assert set(profile._subscriptions.keys()) == { "uuid:dummy-avt1", "uuid:dummy-cm1", "uuid:dummy", } timeouts = sorted(profile._subscriptions.values()) assert timeouts[0] == pytest.approx(now + 90, abs=1) assert timeouts[1] == pytest.approx(now + 150, abs=1) # Test unsubscription await profile.async_unsubscribe_services() assert not profile._subscriptions @pytest.mark.asyncio async def test_subscribe_auto_resubscribe(self) -> None: """Test subscribing, resub, unsub, with auto_resubscribe.""" now = time.monotonic() requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) # Tweak timeouts to get a resubscription in a time suitable for testing. # Resubscription tolerance (60 seconds) + 1 second to get set up entry = requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1") ] requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1") ] = HttpResponse( entry.status_code, { **entry.headers, "timeout": "Second-61", }, entry.body, ) # Test subscription timeout = await profile.async_subscribe_services(auto_resubscribe=True) assert timeout is None assert profile.is_subscribed is True # Check subscriptions are correct assert set(profile._subscriptions.keys()) == { "uuid:dummy-avt1", "uuid:dummy-cm1", "uuid:dummy", } timeouts = sorted(profile._subscriptions.values()) assert timeouts[0] == pytest.approx(now + 61, abs=1) assert timeouts[1] == pytest.approx(now + 150, abs=1) # Check task is running assert isinstance(profile._resubscriber_task, asyncio.Task) assert not profile._resubscriber_task.cancelled() assert not profile._resubscriber_task.done() # Re-tweak timeouts to check resubscription did something entry = requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/AVTransport1") ] requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/AVTransport1") ] = HttpResponse( entry.status_code, { **entry.headers, "timeout": "Second-90", }, entry.body, ) # Wait for an auto-resubscribe await asyncio.sleep(1.5) now = time.monotonic() # Check subscriptions and task again assert set(profile._subscriptions.keys()) == { "uuid:dummy-avt1", "uuid:dummy-cm1", "uuid:dummy", } timeouts = sorted(profile._subscriptions.values()) assert timeouts[0] == pytest.approx(now + 61, abs=1) assert timeouts[1] == pytest.approx(now + 90, abs=1) assert isinstance(profile._resubscriber_task, asyncio.Task) assert not profile._resubscriber_task.cancelled() assert not profile._resubscriber_task.done() assert profile.is_subscribed is True # Unsubscribe await profile.async_unsubscribe_services() # Task and subscriptions should be gone assert profile._resubscriber_task is None assert not profile._subscriptions assert profile.is_subscribed is False @pytest.mark.asyncio async def test_subscribe_fail(self) -> None: """Test subscribing fails with UpnpError if device is offline.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) # First request is fine, 2nd raises an exception, when trying to subscribe requester.exceptions.append(None) requester.exceptions.append(UpnpCommunicationError()) with pytest.raises(UpnpCommunicationError): await profile.async_subscribe_services(True) # Subscriptions and resubscribe task should not exist assert not profile._subscriptions assert profile._resubscriber_task is None assert profile.is_subscribed is False @pytest.mark.asyncio async def test_subscribe_rejected(self) -> None: """Test subscribing rejected by device.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) # All requests give a response error requester.exceptions.append(UpnpResponseError(status=501)) requester.exceptions.append(UpnpResponseError(status=501)) with pytest.raises(UpnpResponseError): await profile.async_subscribe_services(True) # Subscriptions and resubscribe task should not exist assert not profile._subscriptions assert profile._resubscriber_task is None assert profile.is_subscribed is False @pytest.mark.asyncio async def test_auto_resubscribe_fail(self) -> None: """Test auto-resubscription when the device goes offline.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) assert device.available is True # Register an event handler on_event_mock = Mock(return_value=None) profile.on_event = on_event_mock # Setup for auto-resubscription entry = requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1") ] requester.response_map[ ("SUBSCRIBE", "http://dlna_dmr:1234/upnp/event/RenderingControl1") ] = HttpResponse( entry.status_code, {**entry.headers, "timeout": "Second-61"}, entry.body ) await profile.async_subscribe_services(auto_resubscribe=True) # Exception raised when trying to resubscribe and subsequent retry subscribe requester.exceptions.append(UpnpCommunicationError("resubscribe")) requester.exceptions.append(UpnpConnectionError("subscribe")) # Wait for an auto-resubscribe await asyncio.sleep(1.5) # Device should now be offline, and an event notification sent assert device.available is False on_event_mock.assert_called_once_with( device.services["urn:schemas-upnp-org:service:RenderingControl:1"], [] ) # Device will still be subscribed because a notification was sent via # on_event instead of raising an exception. assert profile.is_subscribed is True # Unsubscribe should still work await profile.async_unsubscribe_services() assert profile.is_subscribed is False # Task and subscriptions should be gone assert profile._resubscriber_task is None assert not profile._subscriptions assert profile.is_subscribed is False @pytest.mark.asyncio async def test_subscribe_no_event_handler(self) -> None: """Test no event handler.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") profile = DmrDevice(device, event_handler=None) # Doesn't error, but also doesn't do anything. await profile.async_subscribe_services() @pytest.mark.asyncio async def test_poll_state_variables(self) -> None: """Test polling state variables by calling a Get* action.""" requester = UpnpTestRequester(RESPONSE_MAP) requester.response_map[ ("POST", "http://dlna_dmr:1234/upnp/control/AVTransport1") ] = HttpResponse(200, {}, read_file("dlna/dmr/action_GetPositionInfo.xml")) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler profile = DmrDevice(device, event_handler=event_handler) assert device.available is True # Register an event handler, it should be called when variable is updated on_event_mock = Mock(return_value=None) profile.on_event = on_event_mock assert profile.is_subscribed is False # Check state variables are currently empty assert profile.media_track_number is None assert profile.media_duration is None assert profile.current_track_uri is None assert profile._current_track_meta_data is None assert profile.media_title is None assert profile.media_artist is None # Call the Get action await profile._async_poll_state_variables( "AVT", ["GetPositionInfo"], InstanceID=0 ) # on_event should be called with all changed variables expected_service = device.services["urn:schemas-upnp-org:service:AVTransport:1"] expected_changes = [ expected_service.state_variables[name] for name in ( "CurrentTrack", "CurrentTrackDuration", "CurrentTrackMetaData", "CurrentTrackURI", "RelativeTimePosition", "AbsoluteTimePosition", "RelativeCounterPosition", "AbsoluteCounterPosition", ) ] on_event_mock.assert_called_once_with(expected_service, expected_changes) # Corresponding state variables should be updated assert profile.media_track_number == 1 assert profile.media_duration == 194 assert profile.current_track_uri == "uri://1.mp3" assert profile._current_track_meta_data is not None assert profile.media_title == "Test track" assert profile.media_artist == "A & B > C" @pytest.mark.asyncio async def test_poll_state_variables_missing_action(self) -> None: """Test missing action used when polling state variables is handled gracefully.""" requester = UpnpTestRequester(RESPONSE_MAP) requester.response_map[ ("POST", "http://dlna_dmr:1234/upnp/control/AVTransport1") ] = HttpResponse(200, {}, read_file("dlna/dmr/action_GetPositionInfo.xml")) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") profile = DmrDevice(device, event_handler=None) assert device.available is True # Register an event handler, it should be called when variable is updated on_event_mock = Mock(return_value=None) profile.on_event = on_event_mock assert profile.is_subscribed is False # Check state variables are currently empty assert profile.media_track_number is None assert profile.media_duration is None assert profile.current_track_uri is None assert profile._current_track_meta_data is None assert profile.media_title is None assert profile.media_artist is None # Call an invalid and a valid Get action, in one function call await profile._async_poll_state_variables( "AVT", ["GetInvalidAction", "GetPositionInfo"], InstanceID=0 ) # Missing (invalid) action should have no effect on valid action # on_event should be called with all changed variables expected_service = device.services["urn:schemas-upnp-org:service:AVTransport:1"] expected_changes = [ expected_service.state_variables[name] for name in ( "CurrentTrack", "CurrentTrackDuration", "CurrentTrackMetaData", "CurrentTrackURI", "RelativeTimePosition", "AbsoluteTimePosition", "RelativeCounterPosition", "AbsoluteCounterPosition", ) ] on_event_mock.assert_called_once_with(expected_service, expected_changes) # Corresponding state variables should be updated assert profile.media_track_number == 1 assert profile.media_duration == 194 assert profile.current_track_uri == "uri://1.mp3" assert profile._current_track_meta_data is not None assert profile.media_title == "Test track" assert profile.media_artist == "A & B > C" @pytest.mark.asyncio async def test_poll_state_variables_failed_action(self) -> None: """Test failed action used when polling state variables is handled gracefully.""" requester = UpnpTestRequester(RESPONSE_MAP) # Good action response requester.response_map[ ("POST", "http://dlna_dmr:1234/upnp/control/AVTransport1") ] = HttpResponse(200, {}, read_file("dlna/dmr/action_GetPositionInfo.xml")) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") profile = DmrDevice(device, event_handler=None) assert device.available is True # Register an event handler, it should be called when variable is updated on_event_mock = Mock(return_value=None) profile.on_event = on_event_mock assert profile.is_subscribed is False # Check state variables are currently empty assert profile.media_track_number is None assert profile.media_duration is None assert profile.current_track_uri is None assert profile._current_track_meta_data is None assert profile.media_title is None assert profile.media_artist is None # Failed GetTransportInfo action resulting in an exception requester.exceptions.append( UpnpActionResponseError( status=500, error_code=602, error_desc="Not implemented" ) ) # Call a failing and a valid Get action, in one function call await profile._async_poll_state_variables( "AVT", ["GetTransportInfo", "GetPositionInfo"], InstanceID=0 ) # Missing (invalid) action should have no effect on valid action # on_event should be called with all changed variables expected_service = device.services["urn:schemas-upnp-org:service:AVTransport:1"] expected_changes = [ expected_service.state_variables[name] for name in ( "CurrentTrack", "CurrentTrackDuration", "CurrentTrackMetaData", "CurrentTrackURI", "RelativeTimePosition", "AbsoluteTimePosition", "RelativeCounterPosition", "AbsoluteCounterPosition", ) ] on_event_mock.assert_called_once_with(expected_service, expected_changes) # Corresponding state variables should be updated assert profile.media_track_number == 1 assert profile.media_duration == 194 assert profile.current_track_uri == "uri://1.mp3" assert profile._current_track_meta_data is not None assert profile.media_title == "Test track" assert profile.media_artist == "A & B > C" async_upnp_client-0.44.0/tests/test_advertisement.py000066400000000000000000000061701477256211100227530ustar00rootroot00000000000000"""Unit tests for advertisement.""" from unittest.mock import AsyncMock import pytest from async_upnp_client.advertisement import SsdpAdvertisementListener from async_upnp_client.utils import CaseInsensitiveDict from .common import ( ADVERTISEMENT_HEADERS_DEFAULT, ADVERTISEMENT_REQUEST_LINE, SEARCH_HEADERS_DEFAULT, SEARCH_REQUEST_LINE, ) @pytest.mark.asyncio async def test_receive_ssdp_alive() -> None: """Test handling a ssdp:alive advertisement.""" # pylint: disable=protected-access async_on_alive = AsyncMock() async_on_byebye = AsyncMock() async_on_update = AsyncMock() listener = SsdpAdvertisementListener( async_on_alive=async_on_alive, async_on_byebye=async_on_byebye, async_on_update=async_on_update, ) headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:alive" listener._on_data(ADVERTISEMENT_REQUEST_LINE, headers) async_on_alive.assert_called_with(headers) async_on_byebye.assert_not_called() async_on_update.assert_not_called() @pytest.mark.asyncio async def test_receive_ssdp_byebye() -> None: """Test handling a ssdp:alive advertisement.""" # pylint: disable=protected-access async_on_alive = AsyncMock() async_on_byebye = AsyncMock() async_on_update = AsyncMock() listener = SsdpAdvertisementListener( async_on_alive=async_on_alive, async_on_byebye=async_on_byebye, async_on_update=async_on_update, ) headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:byebye" listener._on_data(ADVERTISEMENT_REQUEST_LINE, headers) async_on_alive.assert_not_called() async_on_byebye.assert_called_with(headers) async_on_update.assert_not_called() @pytest.mark.asyncio async def test_receive_ssdp_update() -> None: """Test handling a ssdp:alive advertisement.""" # pylint: disable=protected-access async_on_alive = AsyncMock() async_on_byebye = AsyncMock() async_on_update = AsyncMock() listener = SsdpAdvertisementListener( async_on_alive=async_on_alive, async_on_byebye=async_on_byebye, async_on_update=async_on_update, ) headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:update" listener._on_data(ADVERTISEMENT_REQUEST_LINE, headers) async_on_alive.assert_not_called() async_on_byebye.assert_not_called() async_on_update.assert_called_with(headers) @pytest.mark.asyncio async def test_receive_ssdp_search_response() -> None: """Test handling a ssdp search response, which is ignored.""" # pylint: disable=protected-access async_on_alive = AsyncMock() async_on_byebye = AsyncMock() async_on_update = AsyncMock() listener = SsdpAdvertisementListener( async_on_alive=async_on_alive, async_on_byebye=async_on_byebye, async_on_update=async_on_update, ) headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) listener._on_data(SEARCH_REQUEST_LINE, headers) async_on_alive.assert_not_called() async_on_byebye.assert_not_called() async_on_update.assert_not_called() async_upnp_client-0.44.0/tests/test_aiohttp.py000066400000000000000000000047051477256211100215530ustar00rootroot00000000000000"""Unit tests for aiohttp.""" # pylint: disable=protected-access from unittest.mock import MagicMock, patch import pytest from async_upnp_client.aiohttp import ( AiohttpNotifyServer, AiohttpRequester, _fixed_host_header, ) from async_upnp_client.const import HttpRequest from async_upnp_client.exceptions import UpnpCommunicationError from .conftest import RESPONSE_MAP, UpnpTestRequester def test_fixed_host_header() -> None: """Test _fixed_host_header.""" # pylint: disable=C1803 assert _fixed_host_header("http://192.168.1.1:8000/desc") == {} assert _fixed_host_header("http://router.local:8000/desc") == {} assert _fixed_host_header("http://[fe80::1%10]:8000/desc") == { "Host": "[fe80::1]:8000" } assert _fixed_host_header("http://192.168.1.1/desc") == {} assert _fixed_host_header("http://router.local/desc") == {} assert _fixed_host_header("http://[fe80::1%10]/desc") == {"Host": "[fe80::1]"} assert _fixed_host_header("https://192.168.1.1/desc") == {} assert _fixed_host_header("https://router.local/desc") == {} assert _fixed_host_header("https://[fe80::1%10]/desc") == {"Host": "[fe80::1]"} assert _fixed_host_header("http://192.168.1.1:8000/root%desc") == {} assert _fixed_host_header("http://router.local:8000/root%desc") == {} assert _fixed_host_header("http://[fe80::1]:8000/root%desc") == {} @pytest.mark.asyncio async def test_server_init() -> None: """Test initialization of an AiohttpNotifyServer.""" requester = UpnpTestRequester(RESPONSE_MAP) server = AiohttpNotifyServer(requester, ("192.168.1.2", 8090)) assert server._loop is not None assert server.listen_host == "192.168.1.2" assert server.listen_port == 8090 assert server.callback_url == "http://192.168.1.2:8090/notify" assert server.event_handler is not None server = AiohttpNotifyServer( requester, ("192.168.1.2", 8090), "http://1.2.3.4:8091/" ) assert server.callback_url == "http://1.2.3.4:8091/" @pytest.mark.asyncio @patch( "async_upnp_client.aiohttp.aiohttp.ClientSession.request", side_effect=UnicodeDecodeError("", b"", 0, 1, ""), ) async def test_client_decode_error(_mock_request: MagicMock) -> None: """Test handling unicode decode error.""" requester = AiohttpRequester() request = HttpRequest("GET", "http://192.168.1.1/desc.xml", {}, None) with pytest.raises(UpnpCommunicationError): await requester.async_http_request(request) async_upnp_client-0.44.0/tests/test_client.py000066400000000000000000000764161477256211100213710ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for client_factory and client modules.""" from datetime import datetime, timedelta, timezone from typing import MutableMapping import defusedxml.ElementTree as DET import pytest from async_upnp_client.client import UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import HttpResponse from async_upnp_client.exceptions import ( UpnpActionError, UpnpActionErrorCode, UpnpActionResponseError, UpnpError, UpnpResponseError, UpnpValueError, UpnpXmlContentError, UpnpXmlParseError, ) from .conftest import RESPONSE_MAP, UpnpTestRequester, read_file class TestUpnpStateVariable: """Tests for UpnpStateVariable.""" @pytest.mark.asyncio async def test_init(self) -> None: """Test initialization of a UpnpDevice.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") assert device assert device.device_type == "urn:schemas-upnp-org:device:MediaRenderer:1" service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") assert service service_by_id = device.service_id("urn:upnp-org:serviceId:RenderingControl") assert service_by_id == service state_var = service.state_variable("Volume") assert state_var action = service.action("GetVolume") assert action argument = action.argument("InstanceID") assert argument @pytest.mark.asyncio async def test_init_embedded_device(self) -> None: """Test initialization of a embedded UpnpDevice.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://igd:1234/device.xml") assert device assert ( device.device_type == "urn:schemas-upnp-org:device:InternetGatewayDevice:1" ) embedded_device = device.embedded_devices[ "urn:schemas-upnp-org:device:WANDevice:1" ] assert embedded_device assert embedded_device.device_type == "urn:schemas-upnp-org:device:WANDevice:1" assert embedded_device.parent_device == device @pytest.mark.asyncio async def test_init_xml(self) -> None: """Test XML is stored on every part of the UpnpDevice.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") assert device.xml is not None service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") assert service.xml is not None state_var = service.state_variable("Volume") assert state_var.xml is not None action = service.action("GetVolume") assert action.xml is not None argument = action.argument("InstanceID") assert argument is not None assert argument.xml is not None @pytest.mark.asyncio async def test_init_bad_xml(self) -> None: """Test missing device element in device description.""" responses = dict(RESPONSE_MAP) responses[("GET", "http://dlna_dmr:1234/device.xml")] = HttpResponse( 200, {}, read_file("dlna/dmr/device_bad_namespace.xml"), ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) with pytest.raises(UpnpXmlContentError): await factory.async_create_device("http://dlna_dmr:1234/device.xml") @pytest.mark.asyncio async def test_empty_descriptor(self) -> None: """Test device with an empty descriptor file called in description.xml.""" responses = dict(RESPONSE_MAP) responses[("GET", "http://dlna_dmr:1234/device.xml")] = HttpResponse( 200, {}, read_file("dlna/dmr/device_with_empty_descriptor.xml"), ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) with pytest.raises(UpnpXmlParseError): await factory.async_create_device("http://dlna_dmr:1234/device.xml") @pytest.mark.asyncio async def test_empty_descriptor_non_strict(self) -> None: """Test device with an empty descriptor file called in description.xml.""" responses = dict(RESPONSE_MAP) responses[("GET", "http://dlna_dmr:1234/device.xml")] = HttpResponse( 200, {}, read_file("dlna/dmr/device_with_empty_descriptor.xml"), ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester, non_strict=True) await factory.async_create_device("http://dlna_dmr:1234/device.xml") @pytest.mark.asyncio async def test_set_value_volume(self) -> None: """Test calling parsing/reading values from UpnpStateVariable.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("Volume") state_var.value = 10 assert state_var.value == 10 assert state_var.upnp_value == "10" state_var.upnp_value = "20" assert state_var.value == 20 assert state_var.upnp_value == "20" @pytest.mark.asyncio async def test_set_value_mute(self) -> None: """Test setting a boolean value.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("Mute") state_var.value = True assert state_var.value is True assert state_var.upnp_value == "1" state_var.value = False assert state_var.value is False assert state_var.upnp_value == "0" state_var.upnp_value = "1" assert state_var.value is True assert state_var.upnp_value == "1" state_var.upnp_value = "0" assert state_var.value is False assert state_var.upnp_value == "0" @pytest.mark.asyncio async def test_value_min_max(self) -> None: """Test min/max restrictions.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("Volume") assert state_var.min_value == 0 assert state_var.max_value == 100 state_var.value = 10 assert state_var.value == 10 try: state_var.value = -10 assert False except UpnpValueError: pass try: state_var.value = 110 assert False except UpnpValueError: pass @pytest.mark.asyncio async def test_value_min_max_validation_disable(self) -> None: """Test if min/max validations can be disabled.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("Volume") # min/max are set assert state_var.min_value == 0 assert state_var.max_value == 100 # min/max are not validated state_var.value = -10 assert state_var.value == -10 state_var.value = 110 assert state_var.value == 110 @pytest.mark.asyncio async def test_value_allowed_value(self) -> None: """Test handling allowed values.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("A_ARG_TYPE_Channel") assert state_var.allowed_values == {"Master"} assert state_var.normalized_allowed_values == {"master"} # should be ok state_var.value = "Master" assert state_var.value == "Master" try: state_var.value = "Left" assert False except UpnpValueError: pass @pytest.mark.asyncio async def test_value_upnp_value_error(self) -> None: """Test handling invalid values in response.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("Volume") # should be ok state_var.upnp_value = "50" assert state_var.value == 50 # should set UpnpStateVariable.UPNP_VALUE_ERROR state_var.upnp_value = "abc" assert state_var.value is None assert state_var.value_unchecked is UpnpStateVariable.UPNP_VALUE_ERROR @pytest.mark.asyncio async def test_value_date_time(self) -> None: """Test parsing of datetime.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("SV1") # should be ok state_var.upnp_value = "1985-04-12T10:15:30" assert state_var.value == datetime(1985, 4, 12, 10, 15, 30) @pytest.mark.asyncio async def test_value_date_time_tz(self) -> None: """Test parsing of date_time with a timezone.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("SV2") assert state_var is not None # should be ok state_var.upnp_value = "1985-04-12T10:15:30+0400" assert state_var.value == datetime( 1985, 4, 12, 10, 15, 30, tzinfo=timezone(timedelta(hours=4)) ) assert state_var.value.tzinfo is not None @pytest.mark.asyncio async def test_send_events(self) -> None: """Test if send_events is properly handled.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("A_ARG_TYPE_InstanceID") # old style assert state_var.send_events is False state_var = service.state_variable("A_ARG_TYPE_Channel") # new style assert state_var.send_events is False state_var = service.state_variable("Volume") # broken/none given assert state_var.send_events is False state_var = service.state_variable("LastChange") assert state_var.send_events is True @pytest.mark.asyncio async def test_big_ints(self) -> None: """Test state variable types i8 and ui8.""" responses = dict(RESPONSE_MAP) responses[("GET", "http://dlna_dms:1234/ContentDirectory_1.xml")] = ( HttpResponse( 200, {}, read_file("scpd_i8.xml"), ) ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dms:1234/device.xml") assert device is not None class TestUpnpAction: """Tests for UpnpAction.""" @pytest.mark.asyncio async def test_init(self) -> None: """Test Initializing a UpnpAction.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") assert action assert action.name == "GetVolume" @pytest.mark.asyncio async def test_valid_arguments(self) -> None: """Test validating arguments of an action.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("SetVolume") # all ok action.validate_arguments(InstanceID=0, Channel="Master", DesiredVolume=10) # invalid type for InstanceID try: action.validate_arguments( InstanceID="0", Channel="Master", DesiredVolume=10 ) assert False except UpnpValueError: pass # missing DesiredVolume try: action.validate_arguments(InstanceID="0", Channel="Master") assert False except UpnpValueError: pass @pytest.mark.asyncio async def test_format_request(self) -> None: """Test the request an action sends.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("SetVolume") service_type = "urn:schemas-upnp-org:service:RenderingControl:1" request = action.create_request( InstanceID=0, Channel="Master", DesiredVolume=10 ) root = DET.fromstring(request.body) namespace = {"rc_service": service_type} assert root.find(".//rc_service:SetVolume", namespace) is not None assert root.find(".//DesiredVolume", namespace) is not None @pytest.mark.asyncio async def test_format_request_escape(self) -> None: """Test escaping the request an action sends.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:AVTransport:1") action = service.action("SetAVTransportURI") service_type = "urn:schemas-upnp-org:service:AVTransport:1" metadata = "test thing" request = action.create_request( InstanceID=0, CurrentURI="http://example.org/file.mp3", CurrentURIMetaData=metadata, ) root = DET.fromstring(request.body) namespace = {"avt_service": service_type} assert root.find(".//avt_service:SetAVTransportURI", namespace) is not None assert root.find(".//CurrentURIMetaData", namespace) is not None assert ( root.findtext(".//CurrentURIMetaData", None, namespace) == "test thing" ) current_uri_metadata_el = root.find(".//CurrentURIMetaData", namespace) assert current_uri_metadata_el is not None # This shouldn't have any children, due to its contents being escaped. assert current_uri_metadata_el.findall("./") == [] @pytest.mark.asyncio async def test_parse_response(self) -> None: """Test calling an action and handling its response.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") service_type = "urn:schemas-upnp-org:service:RenderingControl:1" response_body = read_file("dlna/dmr/action_GetVolume.xml") response = HttpResponse(200, {}, response_body) result = action.parse_response(service_type, response) assert result == {"CurrentVolume": 3} @pytest.mark.asyncio async def test_parse_response_empty(self) -> None: """Test calling an action and handling an empty XML response.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("SetVolume") service_type = "urn:schemas-upnp-org:service:RenderingControl:1" response_body = read_file("dlna/dmr/action_SetVolume.xml") response = HttpResponse(200, {}, response_body) result = action.parse_response(service_type, response) assert result == {} @pytest.mark.asyncio async def test_parse_response_error(self) -> None: """Test calling and action and handling an invalid XML response.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") service_type = "urn:schemas-upnp-org:service:RenderingControl:1" response_body = read_file("dlna/dmr/action_GetVolumeError.xml") response = HttpResponse(200, {}, response_body) with pytest.raises(UpnpActionError) as exc: action.parse_response(service_type, response) assert exc.value.error_code == UpnpActionErrorCode.INVALID_ARGS assert exc.value.error_desc == "Invalid Args" @pytest.mark.asyncio async def test_parse_response_escape(self) -> None: """Test calling an action and properly (not) escaping the response.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:AVTransport:1") action = service.action("GetMediaInfo") service_type = "urn:schemas-upnp-org:service:AVTransport:1" response_body = read_file("dlna/dmr/action_GetMediaInfo.xml") response = HttpResponse(200, {}, response_body) result = action.parse_response(service_type, response) assert result == { "CurrentURI": "uri://1.mp3", "CurrentURIMetaData": "' '' "A & B > C" "" "", "MediaDuration": "00:00:01", "NextURI": "", "NextURIMetaData": "", "NrTracks": 1, "PlayMedium": "NONE", "RecordMedium": "NOT_IMPLEMENTED", "WriteStatus": "NOT_IMPLEMENTED", } @pytest.mark.asyncio async def test_parse_response_no_service_type_version(self) -> None: """Test calling and action and handling a response without service type number.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") service_type = "urn:schemas-upnp-org:service:RenderingControl:1" response_body = read_file("dlna/dmr/action_GetVolumeInvalidServiceType.xml") response = HttpResponse(200, {}, response_body) try: action.parse_response(service_type, response) assert False except UpnpError: pass @pytest.mark.asyncio async def test_parse_response_no_service_type_version_2(self) -> None: """Test calling and action and handling a response without service type number.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:AVTransport:1") action = service.action("GetTransportInfo") service_type = "urn:schemas-upnp-org:service:AVTransport:1" response_body = read_file( "dlna/dmr/action_GetTransportInfoInvalidServiceType.xml" ) response = HttpResponse(200, {}, response_body) try: action.parse_response(service_type, response) assert False except UpnpError: pass @pytest.mark.asyncio async def test_unknown_out_argument(self) -> None: """Test calling an action and handling an unknown out-argument.""" requester = UpnpTestRequester(RESPONSE_MAP) device_url = "http://dlna_dmr:1234/device.xml" service_type = "urn:schemas-upnp-org:service:RenderingControl:1" test_action = "GetVolume" factory = UpnpFactory(requester) device = await factory.async_create_device(device_url) service = device.service(service_type) action = service.action(test_action) response_body = read_file("dlna/dmr/action_GetVolumeExtraOutParameter.xml") response = HttpResponse(200, {}, response_body) try: action.parse_response(service_type, response) assert False except UpnpError: pass factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device(device_url) service = device.service(service_type) action = service.action(test_action) try: action.parse_response(service_type, response) except UpnpError: assert False @pytest.mark.asyncio async def test_response_invalid_xml_namespaces(self) -> None: """Test parsing response with invalid XML namespaces.""" requester = UpnpTestRequester(RESPONSE_MAP) device_url = "http://igd:1234/device.xml" service_type = "urn:schemas-upnp-org:service:WANIPConnection:1" test_action = "DeletePortMapping" # Test strict mode. factory = UpnpFactory(requester) device = await factory.async_create_device(device_url) service = device.find_service(service_type) assert service is not None action = service.action(test_action) response_body = read_file("igd/action_WANPIPConnection_DeletePortMapping.xml") response = HttpResponse(200, {}, response_body) try: action.parse_response(service_type, response) assert False except UpnpError: pass # Test non-strict mode. factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device(device_url) service = device.find_service(service_type) assert service is not None action = service.action(test_action) try: action.parse_response(service_type, response) except UpnpError: assert False class TestUpnpService: """Tests for UpnpService.""" @pytest.mark.asyncio async def test_init(self) -> None: """Test initializing a UpnpService.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") base_url = "http://dlna_dmr:1234" assert service assert service.service_type == "urn:schemas-upnp-org:service:RenderingControl:1" assert service.control_url == base_url + "/upnp/control/RenderingControl1" assert service.event_sub_url == base_url + "/upnp/event/RenderingControl1" assert service.scpd_url == base_url + "/RenderingControl_1.xml" @pytest.mark.asyncio async def test_state_variables_actions(self) -> None: """Test eding a UpnpStateVariable.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") state_var = service.state_variable("Volume") assert state_var action = service.action("GetVolume") assert action @pytest.mark.asyncio async def test_call_action(self) -> None: """Test calling a UpnpAction.""" responses: MutableMapping = { ( "POST", "http://dlna_dmr:1234/upnp/control/RenderingControl1", ): HttpResponse( 200, {}, read_file("dlna/dmr/action_GetVolume.xml"), ) } responses.update(RESPONSE_MAP) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") result = await service.async_call_action(action, InstanceID=0, Channel="Master") assert result["CurrentVolume"] == 3 @pytest.mark.asyncio async def test_soap_fault_http_error(self) -> None: """Test an action response with HTTP error and SOAP fault raises exception.""" responses: MutableMapping = { ( "POST", "http://dlna_dmr:1234/upnp/control/RenderingControl1", ): HttpResponse( 500, {}, read_file("dlna/dmr/action_GetVolumeError.xml"), ) } responses.update(RESPONSE_MAP) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") with pytest.raises(UpnpActionResponseError) as exc: await service.async_call_action(action, InstanceID=0, Channel="Master") assert exc.value.error_code == UpnpActionErrorCode.INVALID_ARGS assert exc.value.error_desc == "Invalid Args" assert exc.value.status == 500 @pytest.mark.asyncio async def test_http_error(self) -> None: """Test an action response with HTTP error and blank body raises exception.""" responses: MutableMapping = { ( "POST", "http://dlna_dmr:1234/upnp/control/RenderingControl1", ): HttpResponse( 500, {}, "", ) } responses.update(RESPONSE_MAP) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") with pytest.raises(UpnpResponseError) as exc: await service.async_call_action(action, InstanceID=0, Channel="Master") assert exc.value.status == 500 @pytest.mark.asyncio async def test_soap_fault_http_ok(self) -> None: """Test an action response with HTTP OK but SOAP fault raises exception.""" responses: MutableMapping = { ( "POST", "http://dlna_dmr:1234/upnp/control/RenderingControl1", ): HttpResponse( 200, {}, read_file("dlna/dmr/action_GetVolumeError.xml"), ) } responses.update(RESPONSE_MAP) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") action = service.action("GetVolume") with pytest.raises(UpnpActionError) as exc: await service.async_call_action(action, InstanceID=0, Channel="Master") assert exc.value.error_code == UpnpActionErrorCode.INVALID_ARGS assert exc.value.error_desc == "Invalid Args" @pytest.mark.parametrize( "rc_doc", [ "dlna/dmr/RenderingControl_1_bad_namespace.xml", # Bad namespace "dlna/dmr/RenderingControl_1_bad_root_tag.xml", # Wrong root tag "dlna/dmr/RenderingControl_1_missing_state_table.xml", # Missing state table ], ) @pytest.mark.asyncio async def test_bad_scpd_strict(self, rc_doc: str) -> None: """Test handling of bad service descriptions in strict mode.""" responses = dict(RESPONSE_MAP) responses[("GET", "http://dlna_dmr:1234/RenderingControl_1.xml")] = ( HttpResponse( 200, {}, read_file(rc_doc), ) ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester) with pytest.raises(UpnpXmlContentError): await factory.async_create_device("http://dlna_dmr:1234/device.xml") @pytest.mark.parametrize( "rc_doc", [ "dlna/dmr/RenderingControl_1_bad_namespace.xml", # Bad namespace "dlna/dmr/RenderingControl_1_bad_root_tag.xml", # Wrong root tag "dlna/dmr/RenderingControl_1_missing_state_table.xml", # Missing state table ], ) @pytest.mark.asyncio async def test_bad_scpd_non_strict_fails(self, rc_doc: str) -> None: """Test bad SCPD in non-strict mode.""" responses = dict(RESPONSE_MAP) responses[("GET", "http://dlna_dmr:1234/RenderingControl_1.xml")] = ( HttpResponse( 200, {}, read_file(rc_doc), ) ) requester = UpnpTestRequester(responses) factory = UpnpFactory(requester, non_strict=True) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") # Known good service assert device.services["urn:schemas-upnp-org:service:AVTransport:1"] # Bad service will also exist, to some extent assert device.services["urn:schemas-upnp-org:service:RenderingControl:1"] async_upnp_client-0.44.0/tests/test_description_cache.py000066400000000000000000000102441477256211100235440ustar00rootroot00000000000000"""Unit tests for description_cache.""" import asyncio from unittest.mock import patch import aiohttp import defusedxml.ElementTree as DET import pytest from async_upnp_client.const import HttpResponse from async_upnp_client.description_cache import DescriptionCache from .conftest import UpnpTestRequester @pytest.mark.asyncio async def test_fetch_parse_success() -> None: """Test properly fetching and parsing a description.""" xml = """ urn:schemas-upnp-org:device:TestDevice:1 uuid:test_udn """ requester = UpnpTestRequester( {("GET", "http://192.168.1.1/desc.xml"): HttpResponse(200, {}, xml)} ) description_cache = DescriptionCache(requester) descr_xml = await description_cache.async_get_description_xml( "http://192.168.1.1/desc.xml" ) assert descr_xml == xml descr_dict = await description_cache.async_get_description_dict( "http://192.168.1.1/desc.xml" ) assert descr_dict == { "deviceType": "urn:schemas-upnp-org:device:TestDevice:1", "UDN": "uuid:test_udn", } @pytest.mark.asyncio async def test_fetch_parse_success_invalid_chars() -> None: """Test fail parsing a description with invalid characters.""" xml = """ urn:schemas-upnp-org:device:TestDevice:1 uuid:test_udn \xff\xff\xff\xff """ requester = UpnpTestRequester( {("GET", "http://192.168.1.1/desc.xml"): HttpResponse(200, {}, xml)} ) description_cache = DescriptionCache(requester) descr_xml = await description_cache.async_get_description_xml( "http://192.168.1.1/desc.xml" ) assert descr_xml == xml descr_dict = await description_cache.async_get_description_dict( "http://192.168.1.1/desc.xml" ) assert descr_dict == { "deviceType": "urn:schemas-upnp-org:device:TestDevice:1", "UDN": "uuid:test_udn", "serialNumber": "ÿÿÿÿ", } @pytest.mark.asyncio @pytest.mark.parametrize("exc", [asyncio.TimeoutError, aiohttp.ClientError]) async def test_fetch_fail(exc: Exception) -> None: """Test fail fetching a description.""" xml = "" requester = UpnpTestRequester( {("GET", "http://192.168.1.1/desc.xml"): HttpResponse(200, {}, xml)} ) requester.exceptions.append(exc) description_cache = DescriptionCache(requester) descr_xml = await description_cache.async_get_description_xml( "http://192.168.1.1/desc.xml" ) assert descr_xml is None descr_dict = await description_cache.async_get_description_dict( "http://192.168.1.1/desc.xml" ) assert descr_dict is None @pytest.mark.asyncio async def test_parsing_fail_invalid_xml() -> None: """Test fail parsing a description with invalid XML.""" xml = """INVALIDXML""" requester = UpnpTestRequester( {("GET", "http://192.168.1.1/desc.xml"): HttpResponse(200, {}, xml)} ) description_cache = DescriptionCache(requester) descr_xml = await description_cache.async_get_description_xml( "http://192.168.1.1/desc.xml" ) assert descr_xml == xml descr_dict = await description_cache.async_get_description_dict( "http://192.168.1.1/desc.xml" ) assert descr_dict is None @pytest.mark.asyncio async def test_parsing_fail_error() -> None: """Test fail parsing a description with invalid XML.""" xml = "" requester = UpnpTestRequester( {("GET", "http://192.168.1.1/desc.xml"): HttpResponse(200, {}, xml)} ) description_cache = DescriptionCache(requester) descr_xml = await description_cache.async_get_description_xml( "http://192.168.1.1/desc.xml" ) assert descr_xml == xml with patch( "async_upnp_client.description_cache.DET.fromstring", side_effect=DET.ParseError, ): descr_dict = await description_cache.async_get_description_dict( "http://192.168.1.1/desc.xml" ) assert descr_dict is None async_upnp_client-0.44.0/tests/test_event_handler.py000066400000000000000000000166131477256211100227220ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Unit tests for event handler module.""" from datetime import timedelta from typing import Generator, Sequence from unittest.mock import Mock, patch import pytest from async_upnp_client.client import UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.const import HttpRequest from async_upnp_client.event_handler import UpnpEventHandlerRegister from .conftest import RESPONSE_MAP, UpnpTestNotifyServer, UpnpTestRequester @pytest.fixture def patched_local_ip() -> Generator: """Patch get_local_ip to `'192.168.1.2"`.""" with patch("async_upnp_client.event_handler.get_local_ip") as mock: yield mock @pytest.mark.asyncio async def test_subscribe() -> None: """Test subscribing to a UpnpService.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") sid, timeout = await event_handler.async_subscribe(service) assert event_handler.service_for_sid("uuid:dummy") == service assert sid == "uuid:dummy" assert timeout == timedelta(seconds=300) assert event_handler.callback_url == "http://192.168.1.2:8090/notify" @pytest.mark.asyncio async def test_subscribe_renew() -> None: """Test renewing an existing subscription to a UpnpService.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") sid, timeout = await event_handler.async_subscribe(service) assert sid == "uuid:dummy" assert event_handler.service_for_sid("uuid:dummy") == service assert timeout == timedelta(seconds=300) sid, timeout = await event_handler.async_resubscribe(service) assert event_handler.service_for_sid("uuid:dummy") == service assert sid == "uuid:dummy" assert timeout == timedelta(seconds=300) @pytest.mark.asyncio async def test_unsubscribe() -> None: """Test unsubscribing from a UpnpService.""" requester = UpnpTestRequester(RESPONSE_MAP) factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") sid, timeout = await event_handler.async_subscribe(service) assert event_handler.service_for_sid("uuid:dummy") == service assert sid == "uuid:dummy" assert timeout == timedelta(seconds=300) old_sid = await event_handler.async_unsubscribe(service) assert event_handler.service_for_sid("uuid:dummy") is None assert old_sid == "uuid:dummy" @pytest.mark.asyncio async def test_on_notify_upnp_event() -> None: """Test handling of a UPnP event.""" changed_vars: Sequence[UpnpStateVariable] = [] def on_event( _self: UpnpService, changed_state_variables: Sequence[UpnpStateVariable] ) -> None: nonlocal changed_vars changed_vars = changed_state_variables requester = UpnpTestRequester(RESPONSE_MAP) notify_server = UpnpTestNotifyServer( requester=requester, source=("192.168.1.2", 8090), ) event_handler = notify_server.event_handler factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") service = device.service("urn:schemas-upnp-org:service:RenderingControl:1") service.on_event = on_event await event_handler.async_subscribe(service) headers = { "NT": "upnp:event", "NTS": "upnp:propchange", "SID": "uuid:dummy", } body = """ 60 """ http_request = HttpRequest( "NOTIFY", "http://dlna_dmr:1234/upnp/event/RenderingControl1", headers, body ) result = await event_handler.handle_notify(http_request) assert result == 200 assert len(changed_vars) == 1 state_var = service.state_variable("Volume") assert state_var.value == 60 @pytest.mark.asyncio async def test_register_device(patched_local_ip: Mock) -> None: """Test registering a device with a UpnpEventHandlerRegister.""" # pylint: disable=redefined-outer-name requester = UpnpTestRequester(RESPONSE_MAP) register = UpnpEventHandlerRegister(requester, UpnpTestNotifyServer) patched_local_ip.return_value = "192.168.1.2" factory = UpnpFactory(requester) device = await factory.async_create_device("http://dlna_dmr:1234/device.xml") event_handler = await register.async_add_device(device) assert event_handler is not None assert event_handler.callback_url == "http://192.168.1.2:0/notify" assert register.has_event_handler_for_device(device) @pytest.mark.asyncio async def test_register_device_different_source_address(patched_local_ip: Mock) -> None: """Test registering two devices with different source IPs with a UpnpEventHandlerRegister.""" # pylint: disable=redefined-outer-name requester = UpnpTestRequester(RESPONSE_MAP) register = UpnpEventHandlerRegister(requester, UpnpTestNotifyServer) factory = UpnpFactory(requester) patched_local_ip.return_value = "192.168.1.2" device_1 = await factory.async_create_device("http://dlna_dmr:1234/device.xml") event_handler_1 = await register.async_add_device(device_1) assert event_handler_1 is not None assert event_handler_1.callback_url == "http://192.168.1.2:0/notify" patched_local_ip.return_value = "192.168.2.2" device_2 = await factory.async_create_device("http://igd:1234/device.xml") event_handler_2 = await register.async_add_device(device_2) assert event_handler_2 is not None assert event_handler_2.callback_url == "http://192.168.2.2:0/notify" @pytest.mark.asyncio async def test_remove_device(patched_local_ip: Mock) -> None: """Test removing a device from a UpnpEventHandlerRegister.""" # pylint: disable=redefined-outer-name requester = UpnpTestRequester(RESPONSE_MAP) register = UpnpEventHandlerRegister(requester, UpnpTestNotifyServer) factory = UpnpFactory(requester) patched_local_ip.return_value = "192.168.1.2" device_1 = await factory.async_create_device("http://dlna_dmr:1234/device.xml") device_2 = await factory.async_create_device("http://igd:1234/device.xml") event_handler_1 = await register.async_add_device(device_1) event_handler_2 = await register.async_add_device(device_2) assert event_handler_1 is event_handler_2 removed_event_handler_1 = await register.async_remove_device(device_1) assert removed_event_handler_1 is None # UpnpEventHandler is still being used removed_event_handler_2 = await register.async_remove_device(device_2) assert removed_event_handler_2 is event_handler_1 async_upnp_client-0.44.0/tests/test_search.py000066400000000000000000000037761477256211100213570ustar00rootroot00000000000000"""Unit tests for search.""" # pylint: disable=protected-access from unittest.mock import AsyncMock import pytest from async_upnp_client.search import SsdpSearchListener from async_upnp_client.ssdp import SSDP_IP_V4 from async_upnp_client.utils import CaseInsensitiveDict from .common import ( ADVERTISEMENT_HEADERS_DEFAULT, ADVERTISEMENT_REQUEST_LINE, SEARCH_HEADERS_DEFAULT, SEARCH_REQUEST_LINE, ) @pytest.mark.asyncio async def test_receive_search_response() -> None: """Test handling a ssdp search response.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpSearchListener(async_callback=async_callback) headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) listener._on_data(SEARCH_REQUEST_LINE, headers) async_callback.assert_called_with(headers) @pytest.mark.asyncio async def test_create_ssdp_listener_with_alternate_target() -> None: """Create a SsdpSearchListener on an alternate target.""" async_callback = AsyncMock() async_connect_callback = AsyncMock() yeelight_target = (SSDP_IP_V4, 1982) yeelight_service_type = "wifi_bulb" listener = SsdpSearchListener( async_callback=async_callback, async_connect_callback=async_connect_callback, search_target=yeelight_service_type, target=yeelight_target, ) assert listener.source == ("0.0.0.0", 0) assert listener.target == yeelight_target assert listener.search_target == yeelight_service_type assert listener.async_callback == async_callback assert listener.async_connect_callback == async_connect_callback @pytest.mark.asyncio async def test_receive_ssdp_alive_advertisement() -> None: """Test handling a ssdp alive advertisement, which is ignored.""" async_callback = AsyncMock() listener = SsdpSearchListener(async_callback=async_callback) headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) listener._on_data(ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_not_called() async_upnp_client-0.44.0/tests/test_server.py000066400000000000000000000263101477256211100214050ustar00rootroot00000000000000"""Test server functionality.""" import asyncio import socket import xml.etree.ElementTree as ET from contextlib import asynccontextmanager, suppress from typing import ( Any, AsyncGenerator, AsyncIterator, Awaitable, Callable, Dict, List, NamedTuple, Optional, Tuple, cast, ) from unittest.mock import Mock import aiohttp import pytest import pytest_asyncio from pytest_aiohttp.plugin import AiohttpClient import async_upnp_client.aiohttp import async_upnp_client.client import async_upnp_client.server from async_upnp_client.client import UpnpStateVariable from async_upnp_client.const import DeviceInfo, ServiceInfo from async_upnp_client.server import ( UpnpServer, UpnpServerDevice, UpnpServerService, callable_action, create_event_var, create_state_var, ) from async_upnp_client.utils import CaseInsensitiveDict from .conftest import read_file class ServerServiceTest(UpnpServerService): """Test Service.""" SERVICE_DEFINITION = ServiceInfo( service_id="urn:upnp-org:serviceId:TestServerService", service_type="urn:schemas-upnp-org:service:TestServerService:1", control_url="/upnp/control/TestServerService", event_sub_url="/upnp/event/TestServerService", scpd_url="/ContentDirectory.xml", xml=ET.Element("server_service"), ) STATE_VARIABLE_DEFINITIONS = { "TestVariable_str": create_state_var("string"), "EventableTextVariable_ui4": create_event_var("ui4", default="0"), "A_ARG_TYPE_In_Var1_str": create_state_var("string"), "A_ARG_TYPE_In_Var2_ui4": create_state_var("ui4"), } @callable_action( name="SetValues", in_args={ "In_Var1_str": "A_ARG_TYPE_In_Var1_str", }, out_args={ "TestVariable_str": "TestVariable_str", "EventableTextVariable_ui4": "EventableTextVariable_ui4", }, ) async def set_values( self, In_Var1_str: str # pylint: disable=invalid-name ) -> Dict[str, UpnpStateVariable]: """Handle action.""" self.state_variable("TestVariable_str").value = In_Var1_str return { "TestVariable_str": self.state_variable("TestVariable_str"), "EventableTextVariable_ui4": self.state_variable( "EventableTextVariable_ui4" ), } def set_eventable(self, value: int) -> None: """Eventable state-variable assignment.""" event_var = self.state_variable("EventableTextVariable_ui4") event_var.value = value class ServerDeviceTest(UpnpServerDevice): """Test device.""" DEVICE_DEFINITION = DeviceInfo( device_type=":urn:schemas-upnp-org:device:TestServerDevice:1", friendly_name="Test Server", manufacturer="Test", manufacturer_url=None, model_name="TestServer", model_url=None, udn="uuid:adca2e25-cbe4-427a-a5c3-9b5931e7b79b", upc=None, model_description="Test Server", model_number="v0.0.1", serial_number="0000001", presentation_url=None, url="/device.xml", icons=[], xml=ET.Element("server_device"), ) EMBEDDED_DEVICES = [] SERVICES = [ServerServiceTest] class AppRunnerMock: """Mock AppRunner.""" # pylint: disable=too-few-public-methods def __init__(self, app: Any, *_args: Any, **_kwargs: Any) -> None: """Initialize.""" self.app = app async def setup(self) -> None: """Configure AppRunner.""" class MockSocket: """Mock socket without 'bind'.""" def __init__(self, sock: socket.socket) -> None: """Initialize.""" self.sock = sock def bind(self, addr: Any) -> None: """Ignore bind.""" def __getattr__(self, name: str) -> Any: """Passthrough.""" return getattr(self.sock, name) class Callback: """HTTP server to process callbacks.""" def __init__(self) -> None: """Initialize.""" self.callback: Optional[ Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]] ] = None self.session = None self.app = aiohttp.web.Application() self.app.router.add_route("NOTIFY", "/{tail:.*}", self.handler) async def start(self, aiohttp_client: Any) -> None: """Generate session.""" self.session = await aiohttp_client(self.app) def set_callback( self, callback: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]] ) -> None: """Assign callback.""" self.callback = callback async def handler(self, request: aiohttp.web.Request) -> aiohttp.web.Response: """Handle callback.""" if self.callback: return await self.callback(request) # pylint: disable=not-callable return aiohttp.web.Response(status=200) @asynccontextmanager async def ClientSession(self) -> AsyncIterator: # pylint: disable=invalid-name """Test session.""" if self.session: yield self.session class UpnpServerTuple(NamedTuple): """Upnp server tuple.""" http_client: AiohttpClient ssdp_sockets: List[socket.socket] callback: Callback server: UpnpServer @pytest_asyncio.fixture async def upnp_server(monkeypatch: Any, aiohttp_client: Any) -> AsyncGenerator: """Fixture to initialize device.""" # pylint: disable=too-few-public-methods ssdp_sockets = [] http_client = None def get_ssdp_socket_mock( *_args: Any, **_kwargs: Any ) -> Tuple[MockSocket, None, None]: sock1, sock2 = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM) ssdp_sockets.append(sock2) return MockSocket(sock1), None, None class TCPSiteMock: """Mock TCP connection.""" def __init__( self, runner: aiohttp.web.AppRunner, *_args: Any, **_kwargs: Any ) -> None: self.app = runner.app self.name = "TCPSiteMock" async def start(self) -> Any: """Create HTTP server.""" nonlocal http_client http_client = cast(AiohttpClient, await aiohttp_client(self.app)) return http_client callback = Callback() monkeypatch.setattr(async_upnp_client.server, "AppRunner", AppRunnerMock) monkeypatch.setattr(async_upnp_client.server, "TCPSite", TCPSiteMock) monkeypatch.setattr( async_upnp_client.server, "get_ssdp_socket", get_ssdp_socket_mock ) monkeypatch.setattr( async_upnp_client.aiohttp, "ClientSession", callback.ClientSession ) server = UpnpServer( ServerDeviceTest, ("127.0.0.1", 0), http_port=80, boot_id=1, config_id=1 ) await server.async_start() assert aiohttp_client await callback.start(aiohttp_client) yield UpnpServerTuple(http_client, ssdp_sockets, callback, server) # await server.async_stop() for sock in ssdp_sockets: sock.close() @pytest.mark.asyncio async def test_init(upnp_server: UpnpServerTuple) -> None: """Test device query.""" # pylint: disable=redefined-outer-name http_client = upnp_server.http_client response = await http_client.get("/device.xml") assert response.status == 200 data = await response.text() assert data == read_file("server/device.xml").strip() @pytest.mark.asyncio async def test_action(upnp_server: UpnpServerTuple) -> None: """Test action execution.""" # pylint: disable=redefined-outer-name http_client = upnp_server.http_client response = await http_client.post( "/upnp/control/TestServerService", data=read_file("server/action_request.xml"), headers={ "content-type": 'text/xml; charset="utf-8"', "user-agent": "Linux/1.0 UPnP/1.1 test/1.0", "soapaction": "urn:schemas-upnp-org:service:TestServerService:1#SetValues", }, ) assert response.status == 200 data = await response.text() assert data == read_file("server/action_response.xml").strip() @pytest.mark.asyncio async def test_subscribe(upnp_server: UpnpServerTuple) -> None: """Test subscription to server event.""" # pylint: disable=redefined-outer-name event = asyncio.Event() expect = 0 async def on_callback(request: aiohttp.web.Request) -> aiohttp.web.Response: nonlocal expect data = await request.read() assert ( data == read_file(f"server/subscribe_response_{expect}.xml").strip().encode() ) expect += 1 event.set() return aiohttp.web.Response(status=200) http_client = upnp_server.http_client callback = upnp_server.callback server = upnp_server.server server_device = server._device # pylint: disable=protected-access assert server_device service = cast( ServerServiceTest, server_device.service("urn:schemas-upnp-org:service:TestServerService:1"), ) callback.set_callback(on_callback) response = await http_client.request( "SUBSCRIBE", "/upnp/event/TestServerService", headers={"CALLBACK": "", "NT": "upnp:event", "TIMEOUT": "Second-30"}, ) assert response.status == 200 data = await response.text() assert not data sid = response.headers.get("SID") assert sid with suppress(asyncio.TimeoutError): await asyncio.wait_for(event.wait(), 2) assert event.is_set() event.clear() while not service.get_subscriber(sid): await asyncio.sleep(0) service.set_eventable(1) with suppress(asyncio.TimeoutError): await asyncio.wait_for(event.wait(), 2) assert event.is_set() def test_send_search_response_ok(upnp_server: UpnpServerTuple) -> None: """Test sending search response without any failure.""" # pylint: disable=redefined-outer-name, protected-access server = upnp_server.server search_responser = server._search_responder assert search_responser assert search_responser._response_transport response_transport = cast(Mock, search_responser._response_transport) assert response_transport response_transport.sendto = Mock(side_effect=None) headers = CaseInsensitiveDict( { "HOST": "192.168.1.100", "man": '"ssdp:discover"', "st": "upnp:rootdevice", "_remote_addr": ("192.168.1.101", 31234), } ) search_responser._on_data("M-SEARCH * HTTP/1.1", headers) response_transport.sendto.assert_called() def test_send_search_response_oserror(upnp_server: UpnpServerTuple) -> None: """Test sending search response and failing, but the error is handled.""" # pylint: disable=redefined-outer-name, protected-access server = upnp_server.server search_responser = server._search_responder assert search_responser assert search_responser._response_transport response_transport = cast(Mock, search_responser._response_transport) assert response_transport response_transport.sendto = Mock(side_effect=None) headers = CaseInsensitiveDict( { "HOST": "192.168.1.100", "man": '"ssdp:discover"', "st": "upnp:rootdevice", "_remote_addr": ("192.168.1.101", 31234), } ) search_responser._on_data("M-SEARCH * HTTP/1.1", headers) response_transport.sendto.assert_called() async_upnp_client-0.44.0/tests/test_ssdp.py000066400000000000000000000236121477256211100210520ustar00rootroot00000000000000"""Unit tests for ssdp.""" import asyncio from unittest.mock import ANY, AsyncMock, MagicMock import pytest from async_upnp_client.ssdp import ( SSDP_PORT, SsdpProtocol, build_ssdp_search_packet, decode_ssdp_packet, fix_ipv6_address_scope_id, get_ssdp_socket, is_ipv4_address, is_ipv6_address, is_valid_ssdp_packet, ) def test_ssdp_search_packet() -> None: """Test SSDP search packet generation.""" msg = build_ssdp_search_packet(("239.255.255.250", 1900), 4, "ssdp:all") assert ( msg == "M-SEARCH * HTTP/1.1\r\n" "HOST:239.255.255.250:1900\r\n" 'MAN:"ssdp:discover"\r\n' "MX:4\r\n" "ST:ssdp:all\r\n" "\r\n".encode() ) def test_ssdp_search_packet_v6() -> None: """Test SSDP search packet generation.""" msg = build_ssdp_search_packet(("FF02::C", 1900, 0, 2), 4, "ssdp:all") assert ( msg == "M-SEARCH * HTTP/1.1\r\n" "HOST:[FF02::C%2]:1900\r\n" 'MAN:"ssdp:discover"\r\n' "MX:4\r\n" "ST:ssdp:all\r\n" "\r\n".encode() ) def test_is_valid_ssdp_packet() -> None: """Test SSDP response validation.""" assert not is_valid_ssdp_packet(b"") msg = ( b"HTTP/1.1 200 OK\r\n" b"Cache-Control: max-age=1900\r\n" b"Location: http://192.168.1.1:80/RootDevice.xml\r\n" b"Server: UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0\r\n" b"ST:urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\r\n" b"USN: uuid:...::WANCommonInterfaceConfig:1\r\n" b"EXT:\r\n\r\n" ) assert is_valid_ssdp_packet(msg) def test_decode_ssdp_packet() -> None: """Test SSDP response decoding.""" msg = ( b"HTTP/1.1 200 OK\r\n" b"Cache-Control: max-age=1900\r\n" b"Location: http://192.168.1.1:80/RootDevice.xml\r\n" b"Server: UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0\r\n" b"ST:urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\r\n" b"USN: uuid:...::WANCommonInterfaceConfig:1\r\n" b"EXT:\r\n\r\n" ) request_line, headers = decode_ssdp_packet( msg, ("local_addr", 1900), ("remote_addr", 12345) ) assert request_line == "HTTP/1.1 200 OK" assert headers == { "cache-control": "max-age=1900", "location": "http://192.168.1.1:80/RootDevice.xml", "server": "UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0", "st": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "usn": "uuid:...::WANCommonInterfaceConfig:1", "ext": "", "_location_original": "http://192.168.1.1:80/RootDevice.xml", "_host": "remote_addr", "_port": 12345, "_local_addr": ("local_addr", 1900), "_remote_addr": ("remote_addr", 12345), "_udn": "uuid:...", "_timestamp": ANY, } def test_decode_ssdp_packet_missing_ending() -> None: """Test SSDP response decoding with a missing end line.""" msg = ( b"HTTP/1.1 200 OK\r\n" b"CACHE-CONTROL: max-age = 1800\r\n" b"DATE:Sun, 25 Apr 2021 16:08:06 GMT\r\n" b"EXT:\r\n" b"LOCATION: http://192.168.107.148:8088/description\r\n" b"SERVER: Ubuntu/10.04 UPnP/1.1 Harmony/16.3\r\n" b"ST: urn:myharmony-com:device:harmony:1\r\n" b"USN: uuid:...::urn:myharmony-com:device:harmony:1\r\n" b"BOOTID.UPNP.ORG:1619366886\r\n" ) request_line, headers = decode_ssdp_packet( msg, ("local_addr", 1900), ("remote_addr", 12345) ) assert request_line == "HTTP/1.1 200 OK" assert headers == { "cache-control": "max-age = 1800", "date": "Sun, 25 Apr 2021 16:08:06 GMT", "location": "http://192.168.107.148:8088/description", "server": "Ubuntu/10.04 UPnP/1.1 Harmony/16.3", "st": "urn:myharmony-com:device:harmony:1", "usn": "uuid:...::urn:myharmony-com:device:harmony:1", "bootid.upnp.org": "1619366886", "ext": "", "_location_original": "http://192.168.107.148:8088/description", "_host": "remote_addr", "_port": 12345, "_local_addr": ("local_addr", 1900), "_remote_addr": ("remote_addr", 12345), "_udn": "uuid:...", "_timestamp": ANY, } def test_decode_ssdp_packet_duplicate_header() -> None: """Test SSDP response decoding with a duplicate header.""" msg = ( b"HTTP/1.1 200 OK\r\n" b"CACHE-CONTROL: max-age = 1800\r\n" b"CACHE-CONTROL: max-age = 1800\r\n\r\n" ) _, headers = decode_ssdp_packet(msg, ("local_addr", 1900), ("remote_addr", 12345)) assert headers == { "cache-control": "max-age = 1800", "_host": "remote_addr", "_port": 12345, "_local_addr": ("local_addr", 1900), "_remote_addr": ("remote_addr", 12345), "_timestamp": ANY, } def test_decode_ssdp_packet_empty_location() -> None: """Test SSDP response decoding with an empty location.""" msg = ( b"HTTP/1.1 200 OK\r\n" b"LOCATION: \r\n" b"CACHE-CONTROL: max-age = 1800\r\n\r\n" ) _, headers = decode_ssdp_packet(msg, ("local_addr", 1900), ("remote_addr", 12345)) assert headers == { "cache-control": "max-age = 1800", "location": "", "_host": "remote_addr", "_port": 12345, "_local_addr": ("local_addr", 1900), "_remote_addr": ("remote_addr", 12345), "_timestamp": ANY, } @pytest.mark.asyncio async def test_ssdp_protocol_handles_broken_headers() -> None: """Test SsdpProtocol is able to handle broken headers.""" msg = b"HTTP/1.1 200 OK\r\n" b"DEFUNCT\r\n" b"CACHE-CONTROL: max-age = 1800\r\n\r\n" addr = ("addr", 123) loop = asyncio.get_event_loop() async_on_data_mock = AsyncMock() protocol = SsdpProtocol(loop, async_on_data=async_on_data_mock) protocol.transport = MagicMock() protocol.datagram_received(msg, addr) async_on_data_mock.assert_not_awaited() def test_decode_ssdp_packet_v6() -> None: """Test SSDP response decoding.""" msg = ( b"HTTP/1.1 200 OK\r\n" b"Cache-Control: max-age=1900\r\n" b"Location: http://[fe80::2]:80/RootDevice.xml\r\n" b"Server: UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0\r\n" b"ST:urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\r\n" b"USN: uuid:...::WANCommonInterfaceConfig:1\r\n" b"EXT:\r\n\r\n" ) request_line, headers = decode_ssdp_packet( msg, ("FF02::C", 1900, 0, 3), ("fe80::1", 123, 0, 3) ) assert request_line == "HTTP/1.1 200 OK" assert headers == { "cache-control": "max-age=1900", "location": "http://[fe80::2%3]:80/RootDevice.xml", "server": "UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0", "st": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "usn": "uuid:...::WANCommonInterfaceConfig:1", "ext": "", "_location_original": "http://[fe80::2]:80/RootDevice.xml", "_host": "fe80::1%3", "_port": 123, "_local_addr": ("FF02::C", 1900, 0, 3), "_remote_addr": ("fe80::1", 123, 0, 3), "_udn": "uuid:...", "_timestamp": ANY, } def test_get_ssdp_socket() -> None: """Test get_ssdp_socket accepts a port.""" # Without a port, should default to SSDP_PORT _, source, target = get_ssdp_socket(("127.0.0.1", 0), ("127.0.0.1", SSDP_PORT)) assert source == ("127.0.0.1", 0) assert target == ("127.0.0.1", SSDP_PORT) # With a different port. _, source, target = get_ssdp_socket( ("127.0.0.1", 0), ("127.0.0.1", 1234), ) assert source == ("127.0.0.1", 0) assert target == ("127.0.0.1", 1234) def test_microsoft_butchers_ssdp() -> None: """Test parsing a `Microsoft Windows Peer Name Resolution Protocol` packet.""" msg = ( b"HTTP/1.1 200 OK\r\n" b"ST:urn:Microsoft Windows Peer Name Resolution Protocol: V4:IPV6:LinkLocal\r\n" b"USN:[fe80::aaaa:bbbb:cccc:dddd]:3540\r\n" b"Location:192.168.1.1\r\n" b"AL:[fe80::aaaa:bbbb:cccc:dddd]:3540\r\n" b'OPT:"http://schemas.upnp.org/upnp/1/0/"; ns=01\r\n' b"01-NLS:abcdef0123456789abcdef012345678\r\n" b"Cache-Control:max-age=14400\r\n" b"Server:Microsoft-Windows-NT/5.1 UPnP/1.0 UPnP-Device-Host/1.0\r\n" b"Ext:\r\n" ) request_line, headers = decode_ssdp_packet( msg, ("239.255.255.250", 1900), ("192.168.1.1", 12345) ) assert request_line == "HTTP/1.1 200 OK" assert headers == { "st": "urn:Microsoft Windows Peer Name Resolution Protocol: V4:IPV6:LinkLocal", "usn": "[fe80::aaaa:bbbb:cccc:dddd]:3540", "location": "192.168.1.1", "al": "[fe80::aaaa:bbbb:cccc:dddd]:3540", "opt": '"http://schemas.upnp.org/upnp/1/0/"; ns=01', "01-nls": "abcdef0123456789abcdef012345678", "cache-control": "max-age=14400", "server": "Microsoft-Windows-NT/5.1 UPnP/1.0 UPnP-Device-Host/1.0", "ext": "", "_location_original": "192.168.1.1", "_host": "192.168.1.1", "_port": 12345, "_local_addr": ("239.255.255.250", 1900), "_remote_addr": ("192.168.1.1", 12345), "_timestamp": ANY, } def test_is_ipv4_address() -> None: """Test is_ipv4_address().""" assert is_ipv4_address(("192.168.1.1", 12345)) assert not is_ipv4_address(("fe80::1", 12345, 0, 6)) def test_is_ipv6_address() -> None: """Test is_ipv6_address().""" assert is_ipv6_address(("fe80::1", 12345, 0, 6)) assert not is_ipv6_address(("192.168.1.1", 12345)) def test_fix_ipv6_address_scope_id() -> None: """Test fix_ipv6_address_scope_id.""" assert fix_ipv6_address_scope_id(("fe80::1", 0, 0, 4)) == ("fe80::1", 0, 0, 4) assert fix_ipv6_address_scope_id(("fe80::1%4", 0, 0, 4)) == ("fe80::1", 0, 0, 4) assert fix_ipv6_address_scope_id(("fe80::1%4", 0, 0, 0)) == ("fe80::1", 0, 0, 4) assert fix_ipv6_address_scope_id(None) is None assert fix_ipv6_address_scope_id(("192.168.1.1", 0)) == ("192.168.1.1", 0) async_upnp_client-0.44.0/tests/test_ssdp_listener.py000066400000000000000000000640771477256211100227710ustar00rootroot00000000000000"""Unit tests for ssdp_listener.""" import asyncio from datetime import datetime, timedelta from typing import AsyncGenerator from unittest.mock import ANY, AsyncMock, Mock, patch import pytest from async_upnp_client.advertisement import SsdpAdvertisementListener from async_upnp_client.const import NotificationSubType, SsdpSource from async_upnp_client.search import SsdpSearchListener from async_upnp_client.ssdp_listener import ( SsdpDevice, SsdpListener, same_headers_differ, ) from async_upnp_client.utils import CaseInsensitiveDict from .common import ( ADVERTISEMENT_HEADERS_DEFAULT, ADVERTISEMENT_REQUEST_LINE, SEARCH_HEADERS_DEFAULT, SEARCH_REQUEST_LINE, ) UDN = ADVERTISEMENT_HEADERS_DEFAULT["_udn"] @pytest.fixture(autouse=True) async def mock_start_listeners() -> AsyncGenerator: """Create listeners but don't call async_start().""" # pylint: disable=protected-access async def async_start(self: SsdpListener) -> None: self._advertisement_listener = SsdpAdvertisementListener( on_alive=self._on_alive, on_update=self._on_update, on_byebye=self._on_byebye, source=self.source, target=self.target, loop=self.loop, ) # await self._advertisement_listener.async_start() self._search_listener = SsdpSearchListener( callback=self._on_search, loop=self.loop, source=self.source, target=self.target, timeout=self.search_timeout, ) # await self._search_listener.async_start() with patch.object(SsdpListener, "async_start", new=async_start) as mock: yield mock async def see_advertisement( ssdp_listener: SsdpListener, request_line: str, headers: CaseInsensitiveDict ) -> None: """See advertisement.""" # pylint: disable=protected-access advertisement_listener = ssdp_listener._advertisement_listener assert advertisement_listener is not None advertisement_listener._on_data(request_line, headers) await asyncio.sleep(0) # Allow callback to run, if called. async def see_search( ssdp_listener: SsdpListener, request_line: str, headers: CaseInsensitiveDict ) -> None: """See search.""" # pylint: disable=protected-access search_listener = ssdp_listener._search_listener assert search_listener is not None search_listener._on_data(request_line, headers) await asyncio.sleep(0) # Allow callback to run, if called. @pytest.mark.asyncio async def test_see_advertisement_alive() -> None: """Test seeing a device through an ssdp:alive-advertisement.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through alive-advertisement. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_ALIVE, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through alive-advertisement, not triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_not_awaited() assert UDN in listener.devices assert listener.devices[UDN].location is not None await listener.async_stop() @pytest.mark.asyncio async def test_see_advertisement_byebye() -> None: """Test seeing a device through an ssdp:byebye-advertisement.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through byebye-advertisement, not triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:byebye" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_not_awaited() assert UDN not in listener.devices # See device for the first time through alive-advertisement, triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_ALIVE, ) assert async_callback.await_args is not None device, dst, _ = async_callback.await_args.args assert device.combined_headers(dst)["NTS"] == NotificationSubType.SSDP_ALIVE assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through byebye-advertisement, triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:byebye" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_BYEBYE, ) assert async_callback.await_args is not None device, dst, _ = async_callback.await_args.args assert device.combined_headers(dst)["NTS"] == NotificationSubType.SSDP_BYEBYE assert UDN not in listener.devices await listener.async_stop() @pytest.mark.asyncio async def test_see_advertisement_update() -> None: """Test seeing a device through a ssdp:update-advertisement.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through alive-advertisement, triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_ALIVE, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through update-advertisement, triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:update" headers["BOOTID.UPNP.ORG"] = "2" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_UPDATE, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None await listener.async_stop() @pytest.mark.asyncio async def test_see_search() -> None: """Test seeing a device through an search.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through search. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See same device again through search, not triggering a change. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_ALIVE, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None await listener.async_stop() @pytest.mark.asyncio async def test_see_search_sync() -> None: """Test seeing a device through an search.""" # pylint: disable=protected-access callback = Mock() listener = SsdpListener(callback=callback) await listener.async_start() # See device for the first time through search. callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) callback.assert_called_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See same device again through search, not triggering a change. callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) callback.assert_called_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_ALIVE, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None await listener.async_stop() @pytest.mark.asyncio async def test_see_search_then_alive() -> None: """Test seeing a device through a search, then a ssdp:alive-advertisement.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through search. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through alive-advertisement, not triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_not_awaited() assert UDN in listener.devices assert listener.devices[UDN].location is not None await listener.async_stop() @pytest.mark.asyncio async def test_see_search_then_update() -> None: """Test seeing a device through a search, then a ssdp:update-advertisement.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through search. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through update-advertisement, triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:update" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_UPDATE, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None await listener.async_stop() @pytest.mark.asyncio async def test_see_search_then_byebye() -> None: """Test seeing a device through a search, then a ssdp:byebye-advertisement.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through search. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through byebye-advertisement, # triggering byebye-callback and device removed. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:byebye" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_BYEBYE, ) assert UDN not in listener.devices await listener.async_stop() @pytest.mark.asyncio async def test_see_search_then_byebye_then_alive() -> None: """Test seeing a device by search, then ssdp:byebye, then ssdp:alive.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through search. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through byebye-advertisement, # triggering byebye-callback and device removed. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:byebye" headers["LOCATION"] = "" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_BYEBYE, ) assert UDN not in listener.devices # See device for the second time through alive-advertisement, not triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.ADVERTISEMENT_ALIVE, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None await listener.async_stop() @pytest.mark.asyncio async def test_purge_devices() -> None: """Test if a device is purged when it times out given the value of the CACHE-CONTROL header.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device through search. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # "Wait" a bit... and purge devices. override_now = headers["_timestamp"] + timedelta(hours=1) listener._device_tracker.purge_devices(override_now) assert UDN not in listener.devices await listener.async_stop() @pytest.mark.asyncio async def test_purge_devices_2() -> None: """Test if a device is purged when it times out, part 2.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device through search. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert UDN in listener.devices assert listener.devices[UDN].location is not None # See anotherdevice through search. async_callback.reset_mock() udn2 = "uuid:device_2" new_timestamp = SEARCH_HEADERS_DEFAULT["_timestamp"] + timedelta(hours=1) device_2_headers = CaseInsensitiveDict( { **SEARCH_HEADERS_DEFAULT, "USN": udn2 + "::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:2", "ST": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:2", "_udn": udn2, "_timestamp": new_timestamp, } ) await see_search(listener, SEARCH_REQUEST_LINE, device_2_headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:2", SsdpSource.SEARCH_CHANGED, ) assert UDN not in listener.devices assert udn2 in listener.devices assert listener.devices[udn2].location is not None await listener.async_stop() def test_same_headers_differ_profile() -> None: """Test same_headers_differ.""" current_headers = CaseInsensitiveDict( { "Cache-Control": "max-age=1900", "location": "http://192.168.1.1:80/RootDevice.xml", "Server": "UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0", "ST": "urn:schemas-upnp-org:device:WANDevice:1", "USN": "uuid:upnp-WANDevice-1_0-123456789abc::urn:schemas-upnp-org:device:WANDevice:1", "EXT": "", "_location_original": "http://192.168.1.1:80/RootDevice.xml", "_timestamp": datetime.now(), "_host": "192.168.1.1", "_port": "1900", "_udn": "uuid:upnp-WANDevice-1_0-123456789abc", "_source": SsdpSource.SEARCH, } ) new_headers = CaseInsensitiveDict( { "Cache-Control": "max-age=1900", "location": "http://192.168.1.1:80/RootDevice.xml", "Server": "UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0 abc", "Date": "Sat, 11 Sep 2021 12:00:00 GMT", "ST": "urn:schemas-upnp-org:device:WANDevice:1", "USN": "uuid:upnp-WANDevice-1_0-123456789abc::urn:schemas-upnp-org:device:WANDevice:1", "EXT": "", "_location_original": "http://192.168.1.1:80/RootDevice.xml", "_timestamp": datetime.now(), "_host": "192.168.1.1", "_port": "1900", "_udn": "uuid:upnp-WANDevice-1_0-123456789abc", "_source": SsdpSource.SEARCH, } ) for _ in range(0, 10000): assert not same_headers_differ(current_headers, new_headers) @pytest.mark.asyncio async def test_see_search_invalid_usn() -> None: """Test invalid USN is ignored.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() advertisement_listener = listener._advertisement_listener assert advertisement_listener is not None # See device for the first time through alive-advertisement. headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) headers["ST"] = ( "urn:Microsoft Windows Peer Name Resolution Protocol: V4:IPV6:LinkLocal" ) headers["USN"] = "[fe80::aaaa:bbbb:cccc:dddd]:3540" del headers["_udn"] advertisement_listener._on_data(SEARCH_REQUEST_LINE, headers) async_callback.assert_not_awaited() await listener.async_stop() @pytest.mark.asyncio async def test_see_search_invalid_location() -> None: """Test headers with invalid location is ignored.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() advertisement_listener = listener._advertisement_listener assert advertisement_listener is not None # See device for the first time through alive-advertisement. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) headers["location"] = "192.168.1.1" advertisement_listener._on_data(SEARCH_REQUEST_LINE, headers) async_callback.assert_not_awaited() await listener.async_stop() @pytest.mark.asyncio @pytest.mark.parametrize( "location", [ "http://127.0.0.1:1234/device.xml", "http://[::1]:1234/device.xml", "http://169.254.12.1:1234/device.xml", ], ) async def test_see_search_localhost_location(location: str) -> None: """Test localhost location (127.0.0.1/[::1]) is ignored.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() advertisement_listener = listener._advertisement_listener assert advertisement_listener is not None # See device for the first time through alive-advertisement. async_callback.reset_mock() headers = CaseInsensitiveDict(SEARCH_HEADERS_DEFAULT) headers["location"] = location advertisement_listener._on_data(SEARCH_REQUEST_LINE, headers) async_callback.assert_not_awaited() await listener.async_stop() @pytest.mark.asyncio async def test_combined_headers() -> None: """Test combined headers.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device for the first time through search. async_callback.reset_mock() headers = CaseInsensitiveDict( {**SEARCH_HEADERS_DEFAULT, "booTID.UPNP.ORG": "0", "Original": "2"} ) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", SsdpSource.SEARCH_CHANGED, ) assert async_callback.await_args is not None device, dst, _ = async_callback.await_args.args assert UDN in listener.devices assert listener.devices[UDN].location is not None # See device for the second time through alive-advertisement, not triggering callback. async_callback.reset_mock() headers = CaseInsensitiveDict( {**ADVERTISEMENT_HEADERS_DEFAULT, "BooTID.UPNP.ORG": "2"} ) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) assert isinstance(device, SsdpDevice) combined = device.combined_headers(dst) assert isinstance(combined, CaseInsensitiveDict) result = {k.lower(): str(v) for k, v in combined.as_dict().items()} del result["_timestamp"] assert result == { "_host": "192.168.1.1", "_port": "1900", "_udn": "uuid:...", "bootid.upnp.org": "2", "cache-control": "max-age=1800", "date": "Fri, 1 Jan 2021 12:00:00 GMT", "location": "http://192.168.1.1:80/RootDevice.xml", "nt": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "nts": NotificationSubType.SSDP_ALIVE, "original": "2", "server": "Linux/2.0 UPnP/1.0 async_upnp_client/0.1", "st": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "usn": "uuid:...::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", } assert combined["original"] == "2" assert combined["bootid.upnp.org"] == "2" assert "_source" not in combined headers = CaseInsensitiveDict( { **ADVERTISEMENT_HEADERS_DEFAULT, "BooTID.UPNP.ORG": "2", "st": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:2", } ) headers["NTS"] = "ssdp:alive" await see_advertisement(listener, ADVERTISEMENT_REQUEST_LINE, headers) combined = device.combined_headers(dst) assert combined["st"] == "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:2" await listener.async_stop() @pytest.mark.asyncio async def test_see_search_device_ipv4_and_ipv6() -> None: """Test seeing the same device via IPv4, then via IPv6.""" # pylint: disable=protected-access async_callback = AsyncMock() listener = SsdpListener(async_callback=async_callback) await listener.async_start() # See device via IPv4, callback should be called. async_callback.reset_mock() location_ipv4 = "http://192.168.1.1:80/RootDevice.xml" headers = CaseInsensitiveDict( { **SEARCH_HEADERS_DEFAULT, "LOCATION": location_ipv4, } ) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, SEARCH_HEADERS_DEFAULT["ST"], SsdpSource.SEARCH_CHANGED ) # See device via IPv6, callback should be called with SsdpSource.SEARCH_ALIVE, # not SEARCH_UPDATE. async_callback.reset_mock() location_ipv6 = "http://[fe80::1]:80/RootDevice.xml" headers = CaseInsensitiveDict( { **SEARCH_HEADERS_DEFAULT, "LOCATION": location_ipv6, } ) await see_search(listener, SEARCH_REQUEST_LINE, headers) async_callback.assert_awaited_once_with( ANY, SEARCH_HEADERS_DEFAULT["ST"], SsdpSource.SEARCH_ALIVE ) assert listener.devices[SEARCH_HEADERS_DEFAULT["_udn"]].locations async_upnp_client-0.44.0/tests/test_utils.py000066400000000000000000000102641477256211100212400ustar00rootroot00000000000000"""Unit tests for dlna.""" import ipaddress import socket from datetime import date, datetime, time, timedelta, timezone import pytest from async_upnp_client.utils import ( CaseInsensitiveDict, async_get_local_ip, get_local_ip, parse_date_time, str_to_time, ) from .common import ADVERTISEMENT_HEADERS_DEFAULT def test_case_insensitive_dict() -> None: """Test CaseInsensitiveDict.""" ci_dict = CaseInsensitiveDict() ci_dict["Key"] = "value" assert ci_dict["Key"] == "value" assert ci_dict["key"] == "value" assert ci_dict["KEY"] == "value" assert CaseInsensitiveDict(key="value") == {"key": "value"} assert CaseInsensitiveDict({"key": "value"}, key="override_value") == { "key": "override_value" } def test_case_insensitive_dict_dict_equality() -> None: """Test CaseInsensitiveDict against dict equality.""" ci_dict = CaseInsensitiveDict() ci_dict["Key"] = "value" assert ci_dict == {"Key": "value"} assert ci_dict == {"key": "value"} assert ci_dict == {"KEY": "value"} def test_case_insensitive_dict_profile() -> None: """Test CaseInsensitiveDict under load, for profiling.""" for _ in range(0, 10000): assert ( CaseInsensitiveDict(ADVERTISEMENT_HEADERS_DEFAULT) == ADVERTISEMENT_HEADERS_DEFAULT ) def test_case_insensitive_dict_equality() -> None: """Test CaseInsensitiveDict equality.""" assert CaseInsensitiveDict(key="value") == CaseInsensitiveDict(KEY="value") def test_str_to_time() -> None: """Test string to time parsing.""" assert str_to_time("0:0:10") == timedelta(hours=0, minutes=0, seconds=10) assert str_to_time("0:10:0") == timedelta(hours=0, minutes=10, seconds=0) assert str_to_time("10:0:0") == timedelta(hours=10, minutes=0, seconds=0) assert str_to_time("0:0:10.10") == timedelta( hours=0, minutes=0, seconds=10, milliseconds=10 ) assert str_to_time("+0:0:10") == timedelta(hours=0, minutes=0, seconds=10) assert str_to_time("-0:0:10") == timedelta(hours=0, minutes=0, seconds=-10) assert str_to_time("") is None assert str_to_time(" ") is None def test_parse_date_time() -> None: """Test string to datetime parsing.""" tz0 = timezone(timedelta(hours=0)) tz1 = timezone(timedelta(hours=1)) assert parse_date_time("2012-07-19") == date(2012, 7, 19) assert parse_date_time("12:28:14") == time(12, 28, 14) assert parse_date_time("2012-07-19 12:28:14") == datetime(2012, 7, 19, 12, 28, 14) assert parse_date_time("2012-07-19T12:28:14") == datetime(2012, 7, 19, 12, 28, 14) assert parse_date_time("12:28:14+01:00") == time(12, 28, 14, tzinfo=tz1) assert parse_date_time("12:28:14 +01:00") == time(12, 28, 14, tzinfo=tz1) assert parse_date_time("2012-07-19T12:28:14z") == datetime( 2012, 7, 19, 12, 28, 14, tzinfo=tz0 ) assert parse_date_time("2012-07-19T12:28:14Z") == datetime( 2012, 7, 19, 12, 28, 14, tzinfo=tz0 ) assert parse_date_time("2012-07-19T12:28:14+01:00") == datetime( 2012, 7, 19, 12, 28, 14, tzinfo=tz1 ) assert parse_date_time("2012-07-19T12:28:14 +01:00") == datetime( 2012, 7, 19, 12, 28, 14, tzinfo=tz1 ) TEST_ADDRESSES = [ None, "8.8.8.8", "8.8.8.8:80", "http://8.8.8.8", "google.com", "http://google.com", "http://google.com:443", ] @pytest.mark.parametrize("target_url", TEST_ADDRESSES) def test_get_local_ip(target_url: str) -> None: """Test getting of a local IP that is not loopback.""" local_ip_str = get_local_ip(target_url) local_ip = ipaddress.ip_address(local_ip_str) assert not local_ip.is_loopback @pytest.mark.asyncio @pytest.mark.parametrize("target_url", TEST_ADDRESSES) async def test_async_get_local_ip(target_url: str) -> None: """Test getting of a local IP that is not loopback.""" addr_family, local_ip_str = await async_get_local_ip(target_url) local_ip = ipaddress.ip_address(local_ip_str) assert not local_ip.is_loopback if local_ip.version == 4: assert addr_family == socket.AddressFamily.AF_INET # pylint: disable=no-member else: assert addr_family == socket.AddressFamily.AF_INET6 # pylint: disable=no-member async_upnp_client-0.44.0/towncrier.toml000066400000000000000000000002331477256211100202310ustar00rootroot00000000000000[tool.towncrier] name = "async_upnp_client" package = "async_upnp_client" package_dir = "async_upnp_client" directory = "changes" filename = "CHANGES.rst" async_upnp_client-0.44.0/tox.ini000066400000000000000000000032551477256211100166420ustar00rootroot00000000000000[tox] envlist = py39, py310, py311, py312, py313, flake8, pylint, codespell, mypy, black, isort [gh-actions] python = 3.9: py39 3.10: py310 3.11: py311 3.12: py312, flake8, pylint, codespell, mypy, black, isort 3.13: py313 [testenv] commands = py.test --cov=async_upnp_client --cov-report=term --cov-report=xml:coverage-{env_name}.xml {posargs} ignore_errors = True deps = pytest == 8.3.3 aiohttp ~= 3.10.9 pytest-asyncio >= 0.24,< 0.26 pytest-aiohttp >=1.0.5,<1.2.0 pytest-cov >= 5.0,< 6.1 coverage >=7.6.1,<7.8.0 asyncmock ~= 0.4.2 [testenv:flake8] basepython = python3 ignore_errors = True deps = flake8 == 7.1.1 flake8-docstrings ~= 1.7.0 pydocstyle ~= 6.3.0 commands = flake8 async_upnp_client tests [testenv:pylint] basepython = python3 ignore_errors = True deps = pylint == 3.3.1 pytest ~= 8.3.3 pytest-asyncio >= 0.24,< 0.26 pytest-aiohttp >=1.0.5,<1.2.0 commands = pylint async_upnp_client tests [testenv:codespell] basepython = python3 ignore_errors = True deps = codespell == 2.3.0 commands = codespell async_upnp_client tests [testenv:mypy] basepython = python3 ignore_errors = True deps = mypy == 1.11.2 python-didl-lite ~= 1.4.0 pytest ~= 8.3.3 aiohttp ~= 3.10.9 pytest-asyncio >= 0.24,< 0.26 pytest-aiohttp >=1.0.5,<1.2.0 commands = mypy --ignore-missing-imports async_upnp_client tests [testenv:black] basepython = python3 ignore_errors = True deps = black == 24.8.0 commands = black --diff async_upnp_client tests [testenv:isort] basepython = python3 ignore_errors = True deps = isort == 5.13.2 commands = isort --check-only --diff --profile=black async_upnp_client tests