File async_upnp_client-0.45.0.obscpio of Package python-async_upnp_client

07070100000000000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002100000000async_upnp_client-0.45.0/.github07070100000001000081A40000000000000000000000016877CBDA000000CD000000000000000000000000000000000000003000000000async_upnp_client-0.45.0/.github/dependabot.ymlversion: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
  - package-ecosystem: "pip"
    directory: "/"
    schedule:
      interval: "weekly"
07070100000002000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002B00000000async_upnp_client-0.45.0/.github/workflows07070100000003000081A40000000000000000000000016877CBDA00000DB7000000000000000000000000000000000000003500000000async_upnp_client-0.45.0/.github/workflows/ci-cd.ymlname: 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.1
        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
07070100000004000081A40000000000000000000000016877CBDA00000300000000000000000000000000000000000000003C00000000async_upnp_client-0.45.0/.github/workflows/pr_towncrier.ymlname: 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
07070100000005000081A40000000000000000000000016877CBDA00000276000000000000000000000000000000000000002400000000async_upnp_client-0.45.0/.gitignore# 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
07070100000006000081A40000000000000000000000016877CBDA000007E3000000000000000000000000000000000000003100000000async_upnp_client-0.45.0/.pre-commit-config.yamlrepos:
  - 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.27
          - 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.12.14
          - pytest-asyncio >= 0.24,< 0.27
          - pytest-aiohttp >=1.0.5,<1.2.0
        files: ^(async_upnp_client|tests)/.+\.py$
07070100000007000081A40000000000000000000000016877CBDA00007EFA000000000000000000000000000000000000002500000000async_upnp_client-0.45.0/CHANGES.rstasync_upnp_client 0.45.0 (2025-07-16)
=====================================

Features
--------

- Add UpnpStateVariable.step_value. (#268)


Bugfixes
--------

- Fix SSDP header parsing.

  aiohttp 3.12.14 starts failing due to including the request line in the header lines to be parsed. (#273)


Misc
----

- #267, #267


async_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
07070100000008000081A40000000000000000000000016877CBDA0000029A000000000000000000000000000000000000002A00000000async_upnp_client-0.45.0/CONTRIBUTING.rstAsync 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 <https://github.com/twisted/towncrier>`_. Please do include change-files in your pull requests. To create a new change-file you can run:

    $ towncrier create <pr_number>.feature  # This creates a change-file for a new feature.
    # towncrier create <pr_number>.bugfix  # This creates a change-file for a bugfix.

After creating the file, you can edit the created file.
07070100000009000081A40000000000000000000000016877CBDA000028F0000000000000000000000000000000000000002500000000async_upnp_client-0.45.0/LICENSE.txtApache License
==============

_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_

### 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.
0707010000000A000081A40000000000000000000000016877CBDA00002DC0000000000000000000000000000000000000002400000000async_upnp_client-0.45.0/README.rstAsync UPnP Client
=================

Asyncio UPnP Client library for Python/asyncio.

Written initially for use in `Home Assistant <https://github.com/home-assistant/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 <https://openconnectivity.org/upnp-specs/UPnP-arch-DeviceArchitecture-v2.0-20200417.pdf>`_ 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 <https://openconnectivity.org/>`_ provides a bundle with all `UPnP Specifications <https://openconnectivity.org/developer/specifications/upnp-resources/upnp/>`_.


Changes
-------

Changes are recorded using `Towncier <https://towncrier.readthedocs.io/>`_. Once a new release is created, towncrier is used to create the file ``CHANGES.rst``.

To create a new change run:

    $ towncrier create <pr-number>.<change type>

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 <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 <https://pypi.org/project/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 <https://github.com/pwaller/pyprof2calltree/>`_ to convert the data and open `kcachegrind <http://kcachegrind.sourceforge.net/html/Home.html>`_. 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": "<Event xmlns=\"urn:schemas-upnp-org:metadata-1-0/AVT_RCS\">\n<InstanceID val=\"0\">\n<Mute channel=\"Master\" val=\"0\"/>\n<Volume channel=\"Master\" val=\"70\"/>\n</InstanceID>\n</Event>\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 <https://github.com/pydron/ifaddr>`_ library. From the (Linux) command line the ``scope_id`` can be found via the `ip` command::

    $ ip address
    ...
    6: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> 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<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
          options=50b<RXCSUM,TXCSUM,VLAN_HWTAGGING,AV,CHANNEL_IO>
          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.)
0707010000000B000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002B00000000async_upnp_client-0.45.0/async_upnp_client0707010000000C000081A40000000000000000000000016877CBDA00000050000000000000000000000000000000000000003700000000async_upnp_client-0.45.0/async_upnp_client/__init__.py# -*- coding: utf-8 -*-
"""async_upnp_client module."""

__version__ = "0.45.0"
0707010000000D000081A40000000000000000000000016877CBDA000014A0000000000000000000000000000000000000003C00000000async_upnp_client-0.45.0/async_upnp_client/advertisement.py# -*- 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", "<no NT>"),
                headers.get_lower("nts", "<no NTS>"),
                headers.get_lower("usn", "<no 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()
0707010000000E000081A40000000000000000000000016877CBDA000031B7000000000000000000000000000000000000003600000000async_upnp_client-0.45.0/async_upnp_client/aiohttp.py# -*- 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"
        )
0707010000000F000081A40000000000000000000000016877CBDA00003854000000000000000000000000000000000000003200000000async_upnp_client-0.45.0/async_upnp_client/cli.py# -*- 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()
07070100000010000081A40000000000000000000000016877CBDA00009687000000000000000000000000000000000000003500000000async_upnp_client-0.45.0/async_upnp_client/client.py# -*- 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"<UpnpDevice({self.udn})>"


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"<UpnpService({self.service_id}, {udn})>"

    def __repr__(self) -> str:
        """To repr."""
        udn = "unbound"
        if self._device:
            udn = self._device.udn
        return f"<UpnpService({self.service_id}, {udn})>"


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"<UpnpAction.Argument({self.name}, {self.direction})>"

    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"<UpnpAction({self.name})>"

    def __repr__(self) -> str:
        """To repr."""
        return f"<UpnpAction({self.name})({self.in_arguments()}) -> {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'<?xml version="1.0"?>'
            f'<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"'
            f' xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">'
            f"<s:Body>"
            f'<u:{self.name} xmlns:u="{service_type}">'
            f"{soap_args}"
            f"</u:{self.name}>"
            f"</s:Body>"
            f"</s:Envelope>"
        )

        # 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]))}</{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._step_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 step_value(self) -> Optional[T]:
        """Step value for this UpnpStateVariable, if defined."""
        if self._step_value is _UNDEFINED:
            step = self._state_variable_info.type_info.allowed_value_range.get("step")
            if step is not None:
                self._step_value = self.coerce_python(step)
            else:
                self._step_value = None
        return self._step_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"<UpnpStateVariable({self.name}, {self.data_type})>"

    def __repr__(self) -> str:
        """To repr."""
        return f"<UpnpStateVariable({self.name}: {self.data_type} = {self.value!r})>"
07070100000011000081A40000000000000000000000016877CBDA00004762000000000000000000000000000000000000003D00000000async_upnp_client-0.45.0/async_upnp_client/client_factory.py# -*- 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
07070100000012000081A40000000000000000000000016877CBDA0000179E000000000000000000000000000000000000003400000000async_upnp_client-0.45.0/async_upnp_client/const.py# -*- 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"
07070100000013000081A40000000000000000000000016877CBDA00001095000000000000000000000000000000000000004000000000async_upnp_client-0.45.0/async_upnp_client/description_cache.py# -*- 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
07070100000014000081A40000000000000000000000016877CBDA0000118B000000000000000000000000000000000000003D00000000async_upnp_client-0.45.0/async_upnp_client/device_updater.py# -*- 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
07070100000015000081A40000000000000000000000016877CBDA00004103000000000000000000000000000000000000003C00000000async_upnp_client-0.45.0/async_upnp_client/event_handler.py# -*- 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
07070100000016000081A40000000000000000000000016877CBDA00001395000000000000000000000000000000000000003900000000async_upnp_client-0.45.0/async_upnp_client/exceptions.py# -*- 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)
07070100000017000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000003400000000async_upnp_client-0.45.0/async_upnp_client/profiles07070100000018000081A40000000000000000000000016877CBDA00000041000000000000000000000000000000000000004000000000async_upnp_client-0.45.0/async_upnp_client/profiles/__init__.py# -*- coding: utf-8 -*-
"""async_upnp_client.profiles module."""
07070100000019000081A40000000000000000000000016877CBDA0000D54E000000000000000000000000000000000000003C00000000async_upnp_client-0.45.0/async_upnp_client/profiles/dlna.py# -*- 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
0707010000001A000081A40000000000000000000000016877CBDA000081E9000000000000000000000000000000000000003B00000000async_upnp_client-0.45.0/async_upnp_client/profiles/igd.py# -*- 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
            ),
        )
0707010000001B000081A40000000000000000000000016877CBDA000004C6000000000000000000000000000000000000003F00000000async_upnp_client-0.45.0/async_upnp_client/profiles/printer.py# -*- 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"]),
        )
0707010000001C000081A40000000000000000000000016877CBDA000043F0000000000000000000000000000000000000003F00000000async_upnp_client-0.45.0/async_upnp_client/profiles/profile.py# -*- 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
0707010000001D000081A40000000000000000000000016877CBDA00000000000000000000000000000000000000000000003400000000async_upnp_client-0.45.0/async_upnp_client/py.typed0707010000001E000081A40000000000000000000000016877CBDA00001A5E000000000000000000000000000000000000003500000000async_upnp_client-0.45.0/async_upnp_client/search.py# -*- 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", "<no 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()
0707010000001F000081A40000000000000000000000016877CBDA0000C8E8000000000000000000000000000000000000003500000000async_upnp_client-0.45.0/async_upnp_client/server.py# -*- 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 <http://...>
            # 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()
07070100000020000081A40000000000000000000000016877CBDA00003EC6000000000000000000000000000000000000003300000000async_upnp_client-0.45.0/async_upnp_client/ssdp.py# -*- 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 = lines[0].strip().decode()

    header_lines = lines[1:] if lines else []
    if header_lines and header_lines[-1] != b"":
        header_lines.append(b"")

    parsed_headers, _ = HeadersParser().parse_headers(header_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]
07070100000021000081A40000000000000000000000016877CBDA00005A01000000000000000000000000000000000000003C00000000async_upnp_client-0.45.0/async_upnp_client/ssdp_listener.py# -*- 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
07070100000022000081A40000000000000000000000016877CBDA0000313E000000000000000000000000000000000000003400000000async_upnp_client-0.45.0/async_upnp_client/utils.py# -*- 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<sign>[-+])?(?P<h>\d+):(?P<m>\d+):(?P<s>\d+)\.?(?P<ms>\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
07070100000023000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002100000000async_upnp_client-0.45.0/changes07070100000024000081A40000000000000000000000016877CBDA0000000C000000000000000000000000000000000000002C00000000async_upnp_client-0.45.0/changes/.gitignore!.gitignore
07070100000025000081A40000000000000000000000016877CBDA00000000000000000000000000000000000000000000002500000000async_upnp_client-0.45.0/codecov.yml07070100000026000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002100000000async_upnp_client-0.45.0/contrib07070100000027000081A40000000000000000000000016877CBDA0000604B000000000000000000000000000000000000003100000000async_upnp_client-0.45.0/contrib/dummy_router.py#!/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())
07070100000028000081A40000000000000000000000016877CBDA00003ECC000000000000000000000000000000000000002D00000000async_upnp_client-0.45.0/contrib/dummy_tv.py#!/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())
07070100000029000081A40000000000000000000000016877CBDA00001D25000000000000000000000000000000000000003100000000async_upnp_client-0.45.0/contrib/media_server.py#!/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="""<?xml version="1.0" encoding="UTF-8"?>
<Features
 xmlns="urn:schemas-upnp-org:av:avs"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="
 urn:schemas-upnp-org:av:avs
 http://www.upnp.org/schemas/av/avs-v1-20060531.xsd">
</Features>"""),
        "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())
0707010000002A000081ED0000000000000000000000016877CBDA000005C6000000000000000000000000000000000000003800000000async_upnp_client-0.45.0/contrib/monitor_igd_traffic.sh#!/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
0707010000002B000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002200000000async_upnp_client-0.45.0/examples0707010000002C000081A40000000000000000000000016877CBDA000009EE000000000000000000000000000000000000003D00000000async_upnp_client-0.45.0/examples/adding_deleting_pinhole.py#!/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()
0707010000002D000081A40000000000000000000000016877CBDA00000CEB000000000000000000000000000000000000004200000000async_upnp_client-0.45.0/examples/adding_deleting_port_mapping.py#!/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()
0707010000002E000081A40000000000000000000000016877CBDA000004E0000000000000000000000000000000000000003000000000async_upnp_client-0.45.0/examples/get_volume.py#!/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())
0707010000002F000081A40000000000000000000000016877CBDA0000055A000000000000000000000000000000000000002800000000async_upnp_client-0.45.0/pyproject.toml[build-system]
build-backend = "setuptools.build_meta"
requires = ["setuptools>=77.0"]

[project]
name = "async_upnp_client"
license = "Apache-2.0"
license-files = ["LICENSE.txt"]
description = "Async UPnP Client"
readme = "README.rst"
authors = [{ name = "Steven Looman", email = "steven.looman@gmail.com" }]
keywords = [
  "ssdp",
  "Simple Service Discovery Protocol",
  "upnp",
  "Universal Plug and Play",
]
classifiers = [
  "Development Status :: 5 - Production/Stable",
  "Intended Audience :: Developers",
  "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",
]
requires-python = ">=3.9"
dependencies = [
  "aiohttp>3.9.0,<4.0",
  "async-timeout>=3.0,<6.0",
  "defusedxml>=0.6.0",
  "python-didl-lite~=1.4.0",
  "voluptuous>=0.15.2",
]
dynamic = ["version"]

[project.urls]
"GitHub: repo" = "https://github.com/StevenLooman/async_upnp_client"

[project.scripts]
upnp-client = "async_upnp_client.cli:main"

[tool.setuptools.dynamic]
version = { attr = "async_upnp_client.__version__" }

[tool.setuptools.packages.find]
include = ["async_upnp_client*"]
07070100000030000081A40000000000000000000000016877CBDA000003E1000000000000000000000000000000000000002300000000async_upnp_client-0.45.0/setup.cfg[bumpversion]
current_version = 0.45.0
commit = True
tag = False
tag_name = {new_version}

[bumpversion:file:async_upnp_client/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"

[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
07070100000031000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000001F00000000async_upnp_client-0.45.0/tests07070100000032000081A40000000000000000000000016877CBDA00000023000000000000000000000000000000000000002B00000000async_upnp_client-0.45.0/tests/__init__.py"""Tests for async_upnp_client."""
07070100000033000081A40000000000000000000000016877CBDA0000054D000000000000000000000000000000000000002900000000async_upnp_client-0.45.0/tests/common.py"""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:...",
    }
)
07070100000034000081A40000000000000000000000016877CBDA00001AA3000000000000000000000000000000000000002B00000000async_upnp_client-0.45.0/tests/conftest.py# -*- 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()
07070100000035000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002800000000async_upnp_client-0.45.0/tests/fixtures07070100000036000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002D00000000async_upnp_client-0.45.0/tests/fixtures/dlna07070100000037000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000003100000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr07070100000038000081A40000000000000000000000016877CBDA0000556D000000000000000000000000000000000000004300000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/AVTransport_1.xml<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>Play</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Speed</name>
          <direction>in</direction>
          <relatedStateVariable>TransportPlaySpeed</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>Stop</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>Next</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>Previous</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetPlayMode</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>NewPlayMode</name>
          <direction>in</direction>
          <relatedStateVariable>CurrentPlayMode</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetMediaInfo</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>NrTracks</name>
          <direction>out</direction>
          <relatedStateVariable>NumberOfTracks</relatedStateVariable>
          <defaultValue>0</defaultValue>
        </argument>
        <argument>
          <name>MediaDuration</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentMediaDuration</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentURI</name>
          <direction>out</direction>
          <relatedStateVariable>AVTransportURI</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentURIMetaData</name>
          <direction>out</direction>
          <relatedStateVariable>AVTransportURIMetaData</relatedStateVariable>
        </argument>
        <argument>
          <name>NextURI</name>
          <direction>out</direction>
          <relatedStateVariable>NextAVTransportURI</relatedStateVariable>
        </argument>
        <argument>
          <name>NextURIMetaData</name>
          <direction>out</direction>
          <relatedStateVariable>NextAVTransportURIMetaData</relatedStateVariable>
        </argument>
        <argument>
          <name>PlayMedium</name>
          <direction>out</direction>
          <relatedStateVariable>PlaybackStorageMedium</relatedStateVariable>
        </argument>
        <argument>
          <name>RecordMedium</name>
          <direction>out</direction>
          <relatedStateVariable>RecordStorageMedium</relatedStateVariable>
        </argument>
        <argument>
          <name>WriteStatus</name>
          <direction>out</direction>
          <relatedStateVariable>RecordMediumWriteStatus</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetDeviceCapabilities</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>PlayMedia</name>
          <direction>out</direction>
          <relatedStateVariable>PossiblePlaybackStorageMedia</relatedStateVariable>
        </argument>
        <argument>
          <name>RecMedia</name>
          <direction>out</direction>
          <relatedStateVariable>PossibleRecordStorageMedia</relatedStateVariable>
        </argument>
        <argument>
          <name>RecQualityModes</name>
          <direction>out</direction>
          <relatedStateVariable>PossibleRecordQualityModes</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetAVTransportURI</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentURI</name>
          <direction>in</direction>
          <relatedStateVariable>AVTransportURI</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentURIMetaData</name>
          <direction>in</direction>
          <relatedStateVariable>AVTransportURIMetaData</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetNextAVTransportURI</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>NextURI</name>
          <direction>in</direction>
          <relatedStateVariable>NextAVTransportURI</relatedStateVariable>
        </argument>
        <argument>
          <name>NextURIMetaData</name>
          <direction>in</direction>
          <relatedStateVariable>NextAVTransportURIMetaData</relatedStateVariable>
        </argument>
      </argumentList>
    </action>

    <action>
      <name>X_PrefetchURI</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>PrefetchURI</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_PrefetchURI</relatedStateVariable>
        </argument>
        <argument>
          <name>PrefetchURIMetaData</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_PrefetchURIMetaData</relatedStateVariable>
        </argument>
      </argumentList>
    </action>

    <action>
      <name>GetTransportSettings</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>PlayMode</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentPlayMode</relatedStateVariable>
        </argument>
        <argument>
          <name>RecQualityMode</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentRecordQualityMode</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetTransportInfo</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentTransportState</name>
          <direction>out</direction>
          <relatedStateVariable>TransportState</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentTransportStatus</name>
          <direction>out</direction>
          <relatedStateVariable>TransportStatus</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentSpeed</name>
          <direction>out</direction>
          <relatedStateVariable>TransportPlaySpeed</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>Pause</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>Seek</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Unit</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_SeekMode</relatedStateVariable>
        </argument>
        <argument>
          <name>Target</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_SeekTarget</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetPositionInfo</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Track</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentTrack</relatedStateVariable>
        </argument>
        <argument>
          <name>TrackDuration</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentTrackDuration</relatedStateVariable>
        </argument>
        <argument>
          <name>TrackMetaData</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentTrackMetaData</relatedStateVariable>
        </argument>
        <argument>
          <name>TrackURI</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentTrackURI</relatedStateVariable>
        </argument>
        <argument>
          <name>RelTime</name>
          <direction>out</direction>
          <relatedStateVariable>RelativeTimePosition</relatedStateVariable>
        </argument>
        <argument>
          <name>AbsTime</name>
          <direction>out</direction>
          <relatedStateVariable>AbsoluteTimePosition</relatedStateVariable>
        </argument>
        <argument>
          <name>RelCount</name>
          <direction>out</direction>
          <relatedStateVariable>RelativeCounterPosition</relatedStateVariable>
        </argument>
        <argument>
          <name>AbsCount</name>
          <direction>out</direction>
          <relatedStateVariable>AbsoluteCounterPosition</relatedStateVariable>
        </argument>
      </argumentList>
    </action>

    <action>
      <name>GetCurrentTransportActions</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Actions</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentTransportActions</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>X_DLNA_GetBytePositionInfo</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>TrackSize</name>
          <direction>out</direction>
          <relatedStateVariable>X_DLNA_CurrentTrackSize</relatedStateVariable>
        </argument>
        <argument>
          <name>RelByte</name>
          <direction>out</direction>
          <relatedStateVariable>X_DLNA_RelativeBytePosition</relatedStateVariable>
        </argument>
        <argument>
          <name>AbsByte</name>
          <direction>out</direction>
          <relatedStateVariable>X_DLNA_AbsoluteBytePosition</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>X_GetStoppedReason</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>StoppedReason</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_StoppedReason</relatedStateVariable>
        </argument>
        <argument>
          <name>StoppedReasonData</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_StoppedReasonData</relatedStateVariable>
        </argument>
      </argumentList>
    </action>

    <action>
      <name>X_PlayerAppHint</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>UpnpClass</name>
          <direction>in</direction>
          <relatedStateVariable>X_ARG_TYPE_UpnpClass</relatedStateVariable>
        </argument>
        <argument>
          <name>PlayerHint</name>
          <direction>in</direction>
          <relatedStateVariable>X_ARG_TYPE_PlayerHint</relatedStateVariable>
        </argument>
      </argumentList>
    </action>

  </actionList>

  <serviceStateTable>
    <stateVariable sendEvents="no">
      <name>TransportState</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>STOPPED</allowedValue>
        <allowedValue>PAUSED_PLAYBACK</allowedValue>
        <allowedValue>PLAYING</allowedValue>
        <allowedValue>TRANSITIONING</allowedValue>
        <allowedValue>NO_MEDIA_PRESENT</allowedValue>
      </allowedValueList>
      <defaultValue>NO_MEDIA_PRESENT</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>TransportStatus</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>OK</allowedValue>
        <allowedValue>ERROR_OCCURRED</allowedValue>
      </allowedValueList>
      <defaultValue>OK</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>TransportPlaySpeed</name>
      <dataType>string</dataType>
      <defaultValue>1</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>NumberOfTracks</name>
      <dataType>ui4</dataType>
      <allowedValueRange>
        <minimum>0</minimum>
        <maximum>4294967295</maximum>
      </allowedValueRange>
      <defaultValue></defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentMediaDuration</name>
      <dataType>string</dataType>
      <defaultValue>00:00:00</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>AVTransportURI</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>AVTransportURIMetaData</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>PlaybackStorageMedium</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>NONE</allowedValue>
        <allowedValue>NETWORK</allowedValue>
      </allowedValueList>
      <defaultValue>NONE</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentTrack</name>
      <dataType>ui4</dataType>
      <allowedValueRange>
        <minimum>0</minimum>
        <maximum>4294967295</maximum>
        <step>1</step>
      </allowedValueRange>
      <defaultValue>0</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentTrackDuration</name>
      <dataType>string</dataType>
      <defaultValue>00:00:00</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentTrackMetaData</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentTrackURI</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>RelativeTimePosition</name>
      <dataType>string</dataType>
      <defaultValue>00:00:00</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>AbsoluteTimePosition</name>
      <dataType>string</dataType>
      <defaultValue>00:00:00</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>NextAVTransportURI</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>NextAVTransportURIMetaData</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentTransportActions</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>RecordStorageMedium</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>NOT_IMPLEMENTED</allowedValue>
      </allowedValueList>
      <defaultValue>NOT_IMPLEMENTED</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>RecordMediumWriteStatus</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>NOT_IMPLEMENTED</allowedValue>
      </allowedValueList>
      <defaultValue>NOT_IMPLEMENTED</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>RelativeCounterPosition</name>
      <dataType>i4</dataType>
      <defaultValue>2147483647</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>AbsoluteCounterPosition</name>
      <dataType>i4</dataType>
      <defaultValue>2147483647</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>PossiblePlaybackStorageMedia</name>
      <dataType>string</dataType>
      <defaultValue>NETWORK</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>PossibleRecordStorageMedia</name>
      <dataType>string</dataType>
      <defaultValue>NOT_IMPLEMENTED</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>PossibleRecordQualityModes</name>
      <dataType>string</dataType>
      <defaultValue>NOT_IMPLEMENTED</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentPlayMode</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>NORMAL</allowedValue>
      </allowedValueList>
      <defaultValue>NORMAL</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>CurrentRecordQualityMode</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>NOT_IMPLEMENTED</allowedValue>
      </allowedValueList>
      <defaultValue>NOT_IMPLEMENTED</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>LastChange</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_InstanceID</name>
      <dataType>ui4</dataType>
    </stateVariable>

    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_PrefetchURI</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_PrefetchURIMetaData</name>
      <dataType>string</dataType>
    </stateVariable>

   <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_SeekMode</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>TRACK_NR</allowedValue>
        <allowedValue>REL_TIME</allowedValue>
        <allowedValue>ABS_TIME</allowedValue>
        <allowedValue>ABS_COUNT</allowedValue>
        <allowedValue>REL_COUNT</allowedValue>
        <allowedValue>X_DLNA_REL_BYTE</allowedValue>
        <allowedValue>FRAME</allowedValue>
      </allowedValueList>
      <defaultValue>REL_TIME</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_SeekTarget</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>X_DLNA_RelativeBytePosition</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>X_DLNA_AbsoluteBytePosition</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>X_DLNA_CurrentTrackSize</name>
      <dataType>string</dataType>
    </stateVariable>

    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_StoppedReason</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_StoppedReasonData</name>
      <dataType>string</dataType>
    </stateVariable>

    <stateVariable sendEvents="no">
      <name>X_ARG_TYPE_UpnpClass</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>object.item.imageItem</allowedValue>
        <allowedValue>object.item.audioItem</allowedValue>
        <allowedValue>object.item.videoItem</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>X_ARG_TYPE_PlayerHint</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>load</allowedValue>
        <allowedValue>unload</allowedValue>
      </allowedValueList>
    </stateVariable>

  </serviceStateTable>
</scpd>
07070100000039000081A40000000000000000000000016877CBDA0000113B000000000000000000000000000000000000004900000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/ConnectionManager_1.xml<?xml version="1.0" encoding="UTF-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>GetProtocolInfo</name>
      <argumentList>
        <argument>
          <name>Source</name>
          <direction>out</direction>
          <relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
        </argument>
        <argument>
          <name>Sink</name>
          <direction>out</direction>
          <relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetCurrentConnectionIDs</name>
      <argumentList>
        <argument>
          <name>ConnectionIDs</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetCurrentConnectionInfo</name>
      <argumentList>
        <argument>
          <name>ConnectionID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
        </argument>
        <argument>
          <name>RcsID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
        </argument>
        <argument>
          <name>AVTransportID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
        </argument>
        <argument>
          <name>ProtocolInfo</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
        </argument>
        <argument>
          <name>PeerConnectionManager</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
        </argument>
        <argument>
          <name>PeerConnectionID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
        </argument>
        <argument>
          <name>Direction</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
        </argument>
        <argument>
          <name>Status</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="yes">
      <name>SourceProtocolInfo</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>SinkProtocolInfo</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>CurrentConnectionIDs</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ConnectionStatus</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>OK</allowedValue>
        <allowedValue>ContentFormatMismatch</allowedValue>
        <allowedValue>InsufficientBandwidth</allowedValue>
        <allowedValue>UnreliableChannel</allowedValue>
        <allowedValue>Unknown</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ConnectionManager</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Direction</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>Input</allowedValue>
        <allowedValue>Output</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ProtocolInfo</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ConnectionID</name>
      <dataType>i4</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_AVTransportID</name>
      <dataType>i4</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_RcsID</name>
      <dataType>i4</dataType>
    </stateVariable>
  </serviceStateTable>
</scpd>
0707010000003A000081A40000000000000000000000016877CBDA00000000000000000000000000000000000000000000004600000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/Empty_Descriptor.xml0707010000003B000081A40000000000000000000000016877CBDA00000F43000000000000000000000000000000000000004800000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/RenderingControl_1.xml<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>GetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentMute</name>
          <direction>out</direction>
          <relatedStateVariable>Mute</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>DesiredMute</name>
          <direction>in</direction>
          <relatedStateVariable>Mute</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentVolume</name>
          <direction>out</direction>
          <relatedStateVariable>Volume</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>DesiredVolume</name>
          <direction>in</direction>
          <relatedStateVariable>Volume</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="yes">
      <name>LastChange</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>Mute</name>
      <dataType>boolean</dataType>
    </stateVariable>
    <stateVariable> <!-- no sendEvents/sendEventsAttribute set -->
      <name>Volume</name>
      <dataType>ui2</dataType>
      <allowedValueRange>
        <minimum>0</minimum>
        <maximum>100</maximum>
        <step>1</step>
      </allowedValueRange>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Channel</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>Master</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable>
      <name>A_ARG_TYPE_InstanceID</name>
      <dataType>ui4</dataType>
      <sendEventsAttribute>no</sendEventsAttribute> <!-- 'old' style -->
    </stateVariable>
    <stateVariable>
      <name>SV1</name>
      <dataType>dateTime</dataType>
    </stateVariable>
    <stateVariable>
      <name>SV2</name>
      <dataType>dateTime.tz</dataType>
    </stateVariable>
  </serviceStateTable>
</scpd>
0707010000003C000081A40000000000000000000000016877CBDA00000F46000000000000000000000000000000000000005600000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/RenderingControl_1_bad_namespace.xml<?xml version="1.0"?>
<scpd xmlns="urn:schemas-tencent-com:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>GetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentMute</name>
          <direction>out</direction>
          <relatedStateVariable>Mute</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>DesiredMute</name>
          <direction>in</direction>
          <relatedStateVariable>Mute</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentVolume</name>
          <direction>out</direction>
          <relatedStateVariable>Volume</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>DesiredVolume</name>
          <direction>in</direction>
          <relatedStateVariable>Volume</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="yes">
      <name>LastChange</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>Mute</name>
      <dataType>boolean</dataType>
    </stateVariable>
    <stateVariable> <!-- no sendEvents/sendEventsAttribute set -->
      <name>Volume</name>
      <dataType>ui2</dataType>
      <allowedValueRange>
        <minimum>0</minimum>
        <maximum>100</maximum>
        <step>1</step>
      </allowedValueRange>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Channel</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>Master</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable>
      <name>A_ARG_TYPE_InstanceID</name>
      <dataType>ui4</dataType>
      <sendEventsAttribute>no</sendEventsAttribute> <!-- 'old' style -->
    </stateVariable>
    <stateVariable>
      <name>SV1</name>
      <dataType>dateTime</dataType>
    </stateVariable>
    <stateVariable>
      <name>SV2</name>
      <dataType>dateTime.tz</dataType>
    </stateVariable>
  </serviceStateTable>
</scpd>
0707010000003D000081A40000000000000000000000016877CBDA00000F49000000000000000000000000000000000000005500000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/RenderingControl_1_bad_root_tag.xml<?xml version="1.0"?>
<notscpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>GetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentMute</name>
          <direction>out</direction>
          <relatedStateVariable>Mute</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>DesiredMute</name>
          <direction>in</direction>
          <relatedStateVariable>Mute</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentVolume</name>
          <direction>out</direction>
          <relatedStateVariable>Volume</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable>
        </argument>
        <argument>
          <name>DesiredVolume</name>
          <direction>in</direction>
          <relatedStateVariable>Volume</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="yes">
      <name>LastChange</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>Mute</name>
      <dataType>boolean</dataType>
    </stateVariable>
    <stateVariable> <!-- no sendEvents/sendEventsAttribute set -->
      <name>Volume</name>
      <dataType>ui2</dataType>
      <allowedValueRange>
        <minimum>0</minimum>
        <maximum>100</maximum>
        <step>1</step>
      </allowedValueRange>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Channel</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>Master</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable>
      <name>A_ARG_TYPE_InstanceID</name>
      <dataType>ui4</dataType>
      <sendEventsAttribute>no</sendEventsAttribute> <!-- 'old' style -->
    </stateVariable>
    <stateVariable>
      <name>SV1</name>
      <dataType>dateTime</dataType>
    </stateVariable>
    <stateVariable>
      <name>SV2</name>
      <dataType>dateTime.tz</dataType>
    </stateVariable>
  </serviceStateTable>
</notscpd>
0707010000003E000081A40000000000000000000000016877CBDA0000075A000000000000000000000000000000000000005C00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/RenderingControl_1_missing_state_table.xml<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>GetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>CurrentMute</name>
          <direction>out</direction>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetMute</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>DesiredMute</name>
          <direction>in</direction>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>CurrentVolume</name>
          <direction>out</direction>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetVolume</name>
      <argumentList>
        <argument>
          <name>InstanceID</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>Channel</name>
          <direction>in</direction>
        </argument>
        <argument>
          <name>DesiredVolume</name>
          <direction>in</direction>
        </argument>
      </argumentList>
    </action>
  </actionList>
</scpd>
0707010000003F000081A40000000000000000000000016877CBDA00000174000000000000000000000000000000000000006000000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetCurrentTransportActions_PlaySeek.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope
	s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
	xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
	<s:Body>
		<u:GetCurrentTransportActionsResponse
			xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
			<Actions>Play,Seek</Actions>
		</u:GetCurrentTransportActionsResponse>
	</s:Body>
</s:Envelope>
07070100000040000081A40000000000000000000000016877CBDA0000016F000000000000000000000000000000000000005C00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetCurrentTransportActions_Stop.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope
	s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
	xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
	<s:Body>
		<u:GetCurrentTransportActionsResponse
			xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
			<Actions>Stop</Actions>
		</u:GetCurrentTransportActionsResponse>
	</s:Body>
</s:Envelope>
07070100000041000081A40000000000000000000000016877CBDA000004F3000000000000000000000000000000000000004900000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetMediaInfo.xml<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <u:GetMediaInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
            <NrTracks>1</NrTracks>
            <MediaDuration>00:00:01</MediaDuration>
            <CurrentURI>uri://1.mp3</CurrentURI>
            <CurrentURIMetaData>&lt;DIDL-Lite xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot; xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:dlna=&quot;urn:schemas-dlna-org:metadata-1-0/&quot; xmlns:sec=&quot;http://www.sec.co.kr/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:xbmc=&quot;urn:schemas-xbmc-org:metadata-1-0/&quot;&gt;&lt;item id=&quot;&quot; parentID=&quot;&quot; refID=&quot;&quot; restricted=&quot;1&quot;&gt;&lt;upnp:artist&gt;A &amp;amp; B &amp;gt; C&lt;/upnp:artist&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;</CurrentURIMetaData>
            <NextURI></NextURI>
            <NextURIMetaData></NextURIMetaData>
            <PlayMedium>NONE</PlayMedium>
            <RecordMedium>NOT_IMPLEMENTED</RecordMedium>
            <WriteStatus>NOT_IMPLEMENTED</WriteStatus>
        </u:GetMediaInfoResponse>
    </s:Body>
</s:Envelope>
07070100000042000081A40000000000000000000000016877CBDA000004F2000000000000000000000000000000000000004C00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetPositionInfo.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:GetPositionInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
      <Track>1</Track>
      <TrackDuration>00:03:14</TrackDuration>
      <TrackMetaData>&lt;DIDL-Lite xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot; xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:dlna=&quot;urn:schemas-dlna-org:metadata-1-0/&quot; xmlns:sec=&quot;http://www.sec.co.kr/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:xbmc=&quot;urn:schemas-xbmc-org:metadata-1-0/&quot;&gt;&lt;item id=&quot;&quot; parentID=&quot;&quot; refID=&quot;&quot; restricted=&quot;1&quot;&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;dc:title&gt;Test track&lt;/dc:title&gt;&lt;upnp:artist&gt;A &amp;amp; B &amp;gt; C&lt;/upnp:artist&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;</TrackMetaData>
      <TrackURI>uri://1.mp3</TrackURI>
      <RelTime>00:00:00</RelTime>
      <AbsTime>00:00:00</AbsTime>
      <RelCount>2147483647</RelCount>
      <AbsCount>2147483647</AbsCount>
    </u:GetPositionInfoResponse>
  </s:Body>
</s:Envelope>
07070100000043000081A40000000000000000000000016877CBDA000001FA000000000000000000000000000000000000005F00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetTransportInfoInvalidServiceType.xml<ns0:Envelope xmlns:ns0="http://schemas.xmlsoap.org/soap/envelope/"
              xmlns:ns1="urn:upnp-org:serviceId:AVTransport"
              ns0:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <ns0:Body>
        <ns1:GetTransportInfoResponse>
            <CurrentTransportState>STOPPED</CurrentTransportState>
            <CurrentTransportStatus>OK</CurrentTransportStatus>
            <CurrentSpeed>1</CurrentSpeed>
        </ns1:GetTransportInfoResponse>
    </ns0:Body>
</ns0:Envelope>
07070100000044000081A40000000000000000000000016877CBDA0000017D000000000000000000000000000000000000004600000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetVolume.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
            <CurrentVolume>3</CurrentVolume>
        </u:GetVolumeResponse>
    </s:Body>
</s:Envelope>
07070100000045000081A40000000000000000000000016877CBDA0000025D000000000000000000000000000000000000004B00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetVolumeError.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <s:Fault>
            <faultcode>s:Client</faultcode>
            <faultstring>UPnPError</faultstring>
            <detail>
                <UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
                    <errorCode>402</errorCode>
                    <errorDescription>Invalid Args</errorDescription>
                </UPnPError>
            </detail>
        </s:Fault>
    </s:Body>
</s:Envelope>
07070100000046000081A40000000000000000000000016877CBDA000001AA000000000000000000000000000000000000005700000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetVolumeExtraOutParameter.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
            <CurrentVolume>3</CurrentVolume>
            <IsMute>False</IsMute>
        </u:GetVolumeResponse>
    </s:Body>
</s:Envelope>
07070100000047000081A40000000000000000000000016877CBDA0000017B000000000000000000000000000000000000005800000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_GetVolumeInvalidServiceType.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl">
            <CurrentVolume>3</CurrentVolume>
        </u:GetVolumeResponse>
    </s:Body>
</s:Envelope>
07070100000048000081A40000000000000000000000016877CBDA00000133000000000000000000000000000000000000004600000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/action_SetVolume.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <u:SetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1" />
    </s:Body>
</s:Envelope>
07070100000049000081A40000000000000000000000016877CBDA00000A59000000000000000000000000000000000000003C00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/device.xml<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:pnpx="http://schemas.microsoft.com/windows/pnpx/2005/11" xmlns:df="http://schemas.microsoft.com/windows/2008/09/devicefoundation" xmlns:sec="http://www.sec.co.kr/dlna">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
    <friendlyName>Dummy TV</friendlyName>
    <manufacturer>Steven</manufacturer>
    <modelDescription>Dummy TV DMR</modelDescription>
    <modelName>DummyTV v1</modelName>
    <UDN>uuid:00000000-0000-0000-0000-000000000000</UDN>
    <iconList>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.jpg</url>
        </icon>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.jpg</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.png</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.png</url>
        </icon>
    </iconList>
    <serviceList>
      <service>
        <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
        <controlURL>/upnp/control/RenderingControl1</controlURL>
        <eventSubURL>/upnp/event/RenderingControl1</eventSubURL>
        <SCPDURL>/RenderingControl_1.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
        <controlURL>/upnp/control/ConnectionManager1</controlURL>
        <eventSubURL>/upnp/event/ConnectionManager1</eventSubURL>
        <SCPDURL>/ConnectionManager_1.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
        <controlURL>/upnp/control/AVTransport1</controlURL>
        <eventSubURL>/upnp/event/AVTransport1</eventSubURL>
        <SCPDURL>/AVTransport_1.xml</SCPDURL>
      </service>
    </serviceList>
  </device>
</root>
0707010000004A000081A40000000000000000000000016877CBDA000009A6000000000000000000000000000000000000004A00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/device_bad_namespace.xml<?xml version="1.0"?>
<root xmlns="urn:made-up-org:device-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
    <friendlyName>Dummy TV</friendlyName>
    <manufacturer>Steven</manufacturer>
    <modelDescription>Dummy TV DMR</modelDescription>
    <modelName>DummyTV v1</modelName>
    <UDN>uuid:00000000-0000-0000-0000-000000000000</UDN>
    <iconList>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.jpg</url>
        </icon>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.jpg</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.png</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.png</url>
        </icon>
    </iconList>
    <serviceList>
      <service>
        <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
        <controlURL>/upnp/control/RenderingControl1</controlURL>
        <eventSubURL>/upnp/event/RenderingControl1</eventSubURL>
        <SCPDURL>/RenderingControl_1.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
        <controlURL>/upnp/control/ConnectionManager1</controlURL>
        <eventSubURL>/upnp/event/ConnectionManager1</eventSubURL>
        <SCPDURL>/ConnectionManager_1.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
        <controlURL>/upnp/control/AVTransport1</controlURL>
        <eventSubURL>/upnp/event/AVTransport1</eventSubURL>
        <SCPDURL>/AVTransport_1.xml</SCPDURL>
      </service>
    </serviceList>
  </device>
</root>
0707010000004B000081A40000000000000000000000016877CBDA00000C4E000000000000000000000000000000000000004500000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/device_embedded.xml<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:pnpx="http://schemas.microsoft.com/windows/pnpx/2005/11" xmlns:df="http://schemas.microsoft.com/windows/2008/09/devicefoundation" xmlns:sec="http://www.sec.co.kr/dlna">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>RootDevice:1</deviceType>
    <friendlyName>Dummy Root Device</friendlyName>
    <manufacturer>Steven</manufacturer>
    <modelDescription>Dummy TV Root device</modelDescription>
    <modelName>DummyRoot v1</modelName>
    <UDN>uuid:00000000-0000-0000-0000-000000000000</UDN>
    <iconList>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.jpg</url>
        </icon>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.jpg</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.png</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.png</url>
        </icon>
    </iconList>
    <serviceList/>
    <deviceList>
      <device>
        <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
        <friendlyName>Dummy TV</friendlyName>
        <manufacturer>Steven</manufacturer>
        <modelDescription>Dummy TV Root device</modelDescription>
        <modelName>DummyTV v1</modelName>
        <UDN>uuid:00000000-0000-0000-0000-000000000001</UDN>
        <serviceList>
          <service>
            <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
            <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
            <controlURL>/upnp/control/RenderingControl1</controlURL>
            <eventSubURL>/upnp/event/RenderingControl1</eventSubURL>
            <SCPDURL>/RenderingControl_1.xml</SCPDURL>
          </service>
          <service>
            <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
            <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
            <controlURL>/upnp/control/ConnectionManager1</controlURL>
            <eventSubURL>/upnp/event/ConnectionManager1</eventSubURL>
            <SCPDURL>/ConnectionManager_1.xml</SCPDURL>
          </service>
          <service>
            <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
            <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
            <controlURL>/upnp/control/AVTransport1</controlURL>
            <eventSubURL>/upnp/event/AVTransport1</eventSubURL>
            <SCPDURL>/AVTransport_1.xml</SCPDURL>
          </service>
        </serviceList>
      </device>
    </deviceList>
  </device>
</root>
0707010000004C000081A40000000000000000000000016877CBDA0000060B000000000000000000000000000000000000004700000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/device_incomplete.xml<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:pnpx="http://schemas.microsoft.com/windows/pnpx/2005/11" xmlns:df="http://schemas.microsoft.com/windows/2008/09/devicefoundation" xmlns:sec="http://www.sec.co.kr/dlna">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
    <friendlyName>Dummy TV</friendlyName>
    <manufacturer>Steven</manufacturer>
    <modelDescription>Dummy TV DMR</modelDescription>
    <modelName>DummyTV v1</modelName>
    <UDN>uuid:00000000-0000-0000-0000-000000000000</UDN>
    <iconList>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.jpg</url>
        </icon>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.jpg</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.png</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.png</url>
        </icon>
    </iconList>
    <serviceList/>
  </device>
</root>
0707010000004D000081A40000000000000000000000016877CBDA00000B95000000000000000000000000000000000000005200000000async_upnp_client-0.45.0/tests/fixtures/dlna/dmr/device_with_empty_descriptor.xml<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:pnpx="http://schemas.microsoft.com/windows/pnpx/2005/11" xmlns:df="http://schemas.microsoft.com/windows/2008/09/devicefoundation" xmlns:sec="http://www.sec.co.kr/dlna">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
    <friendlyName>Dummy TV</friendlyName>
    <manufacturer>Steven</manufacturer>
    <modelDescription>Dummy TV DMR</modelDescription>
    <modelName>DummyTV v1</modelName>
    <UDN>uuid:00000000-0000-0000-0000-000000000000</UDN>
    <iconList>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.jpg</url>
        </icon>
        <icon>
            <mimetype>image/jpeg</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.jpg</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>48</width>
            <height>48</height>
            <depth>24</depth>
            <url>/device_icon_48.png</url>
        </icon>
        <icon>
            <mimetype>image/png</mimetype>
            <width>120</width>
            <height>120</height>
            <depth>24</depth>
            <url>/device_icon_120.png</url>
        </icon>
    </iconList>
    <serviceList>
      <service>
        <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
        <controlURL>/upnp/control/RenderingControl1</controlURL>
        <eventSubURL>/upnp/event/RenderingControl1</eventSubURL>
        <SCPDURL>/RenderingControl_1.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
        <controlURL>/upnp/control/ConnectionManager1</controlURL>
        <eventSubURL>/upnp/event/ConnectionManager1</eventSubURL>
        <SCPDURL>/ConnectionManager_1.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
        <controlURL>/upnp/control/AVTransport1</controlURL>
        <eventSubURL>/upnp/event/AVTransport1</eventSubURL>
        <SCPDURL>/AVTransport_1.xml</SCPDURL>
      </service>
      <service>
         <serviceType>urn:schemas-tencent-com:service:QPlay:1</serviceType>
         <serviceId>urn:tencent-com:serviceId:QPlay</serviceId>
         <controlURL>/QPlay</controlURL>
         <eventSubURL>/QPlay/eventSub</eventSubURL>
         <SCPDURL>/Empty_Descriptor.xml</SCPDURL>
      </service>
    </serviceList>
  </device>
</root>
0707010000004E000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000003100000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms0707010000004F000081A40000000000000000000000016877CBDA0000112A000000000000000000000000000000000000004900000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/ConnectionManager_1.xml<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>GetProtocolInfo</name>
      <argumentList>
        <argument>
          <name>Source</name>
          <direction>out</direction>
          <relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
        </argument>
        <argument>
          <name>Sink</name>
          <direction>out</direction>
          <relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetCurrentConnectionIDs</name>
      <argumentList>
        <argument>
          <name>ConnectionIDs</name>
          <direction>out</direction>
          <relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetCurrentConnectionInfo</name>
      <argumentList>
        <argument>
          <name>ConnectionID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
        </argument>
        <argument>
          <name>RcsID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
        </argument>
        <argument>
          <name>AVTransportID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
        </argument>
        <argument>
          <name>ProtocolInfo</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
        </argument>
        <argument>
          <name>PeerConnectionManager</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
        </argument>
        <argument>
          <name>PeerConnectionID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
        </argument>
        <argument>
          <name>Direction</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
        </argument>
        <argument>
          <name>Status</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="yes">
      <name>SourceProtocolInfo</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>SinkProtocolInfo</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>CurrentConnectionIDs</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ConnectionStatus</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>OK</allowedValue>
        <allowedValue>ContentFormatMismatch</allowedValue>
        <allowedValue>InsufficientBandwidth</allowedValue>
        <allowedValue>UnreliableChannel</allowedValue>
        <allowedValue>Unknown</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ConnectionManager</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Direction</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>Input</allowedValue>
        <allowedValue>Output</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ProtocolInfo</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ConnectionID</name>
      <dataType>i4</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_AVTransportID</name>
      <dataType>i4</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_RcsID</name>
      <dataType>i4</dataType>
    </stateVariable>
  </serviceStateTable>
</scpd>
07070100000050000081A40000000000000000000000016877CBDA00001E2D000000000000000000000000000000000000004800000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/ContentDirectory_1.xml<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>GetSearchCapabilities</name>
      <argumentList>
        <argument>
          <name>SearchCaps</name>
          <direction>out</direction>
          <relatedStateVariable>SearchCapabilities</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetSortCapabilities</name>
      <argumentList>
        <argument>
          <name>SortCaps</name>
          <direction>out</direction>
          <relatedStateVariable>SortCapabilities</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>GetSystemUpdateID</name>
      <argumentList>
        <argument>
          <name>Id</name>
          <direction>out</direction>
          <relatedStateVariable>SystemUpdateID</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>Browse</name>
      <argumentList>
        <argument>
          <name>ObjectID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
        </argument>
        <argument>
          <name>BrowseFlag</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
        </argument>
        <argument>
          <name>Filter</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
        </argument>
        <argument>
          <name>StartingIndex</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
        </argument>
        <argument>
          <name>RequestedCount</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
        </argument>
        <argument>
          <name>SortCriteria</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
        </argument>
        <argument>
          <name>Result</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
        </argument>
        <argument>
          <name>NumberReturned</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
        </argument>
        <argument>
          <name>TotalMatches</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
        </argument>
        <argument>
          <name>UpdateID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>Search</name>
      <argumentList>
        <argument>
          <name>ContainerID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
        </argument>
        <argument>
          <name>SearchCriteria</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_SearchCriteria</relatedStateVariable>
        </argument>
        <argument>
          <name>Filter</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
        </argument>
        <argument>
          <name>StartingIndex</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
        </argument>
        <argument>
          <name>RequestedCount</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
        </argument>
        <argument>
          <name>SortCriteria</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
        </argument>
        <argument>
          <name>Result</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
        </argument>
        <argument>
          <name>NumberReturned</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
        </argument>
        <argument>
          <name>TotalMatches</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
        </argument>
        <argument>
          <name>UpdateID</name>
          <direction>out</direction>
          <relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>UpdateObject</name>
      <argumentList>
        <argument>
          <name>ObjectID</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
        </argument>
        <argument>
          <name>CurrentTagValue</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_TagValueList</relatedStateVariable>
        </argument>
        <argument>
          <name>NewTagValue</name>
          <direction>in</direction>
          <relatedStateVariable>A_ARG_TYPE_TagValueList</relatedStateVariable>
        </argument>
      </argumentList>
    </action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="yes">
      <name>TransferIDs</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_ObjectID</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Result</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_SearchCriteria</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_BrowseFlag</name>
      <dataType>string</dataType>
      <allowedValueList>
        <allowedValue>BrowseMetadata</allowedValue>
        <allowedValue>BrowseDirectChildren</allowedValue>
      </allowedValueList>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Filter</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_SortCriteria</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Index</name>
      <dataType>ui4</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_Count</name>
      <dataType>ui4</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_UpdateID</name>
      <dataType>ui4</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>A_ARG_TYPE_TagValueList</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>SearchCapabilities</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="no">
      <name>SortCapabilities</name>
      <dataType>string</dataType>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>SystemUpdateID</name>
      <dataType>ui4</dataType>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>ContainerUpdateIDs</name>
      <dataType>string</dataType>
    </stateVariable>
  </serviceStateTable>
</scpd>
07070100000051000081A40000000000000000000000016877CBDA00000685000000000000000000000000000000000000004E00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/action_Browse_children_0.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Result>&lt;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/"&gt;
&lt;container id="64" parentID="0" restricted="1" searchable="1" childCount="4"&gt;&lt;dc:title&gt;Browse Folders&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;container id="1" parentID="0" restricted="1" searchable="1" childCount="7"&gt;&lt;dc:title&gt;Music&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;container id="3" parentID="0" restricted="1" searchable="1" childCount="5"&gt;&lt;dc:title&gt;Pictures&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;container id="2" parentID="0" restricted="1" searchable="1" childCount="3"&gt;&lt;dc:title&gt;Video&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;/DIDL-Lite&gt;</Result>
<NumberReturned>4</NumberReturned>
<TotalMatches>4</TotalMatches>
<UpdateID>2333</UpdateID></u:BrowseResponse></s:Body></s:Envelope>
07070100000052000081A40000000000000000000000016877CBDA00000593000000000000000000000000000000000000004E00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/action_Browse_children_2.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Result>&lt;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/"&gt;
&lt;container id="2$8" parentID="2" restricted="1" searchable="1" childCount="583"&gt;&lt;dc:title&gt;All Video&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;container id="2$15" parentID="2" restricted="1" searchable="1" childCount="2"&gt;&lt;dc:title&gt;Folders&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;container id="2$FF0" parentID="2" restricted="1" searchable="0" childCount="50"&gt;&lt;dc:title&gt;Recently Added&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;/DIDL-Lite&gt;</Result>
<NumberReturned>3</NumberReturned>
<TotalMatches>3</TotalMatches>
<UpdateID>2333</UpdateID></u:BrowseResponse></s:Body></s:Envelope>
07070100000053000081A40000000000000000000000016877CBDA00000278000000000000000000000000000000000000005100000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/action_Browse_children_item.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Result>&lt;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/"&gt;
&lt;/DIDL-Lite&gt;</Result>
<NumberReturned>0</NumberReturned>
<TotalMatches>0</TotalMatches>
<UpdateID>2333</UpdateID></u:BrowseResponse></s:Body></s:Envelope>
07070100000054000081A40000000000000000000000016877CBDA00000483000000000000000000000000000000000000004E00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/action_Browse_metadata_0.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Result>&lt;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/"&gt;
&lt;container id="0" parentID="-1" restricted="1" searchable="1" childCount="4"&gt;&lt;upnp:searchClass includeDerived="1"&gt;object.item.audioItem&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived="1"&gt;object.item.imageItem&lt;/upnp:searchClass&gt;&lt;upnp:searchClass includeDerived="1"&gt;object.item.videoItem&lt;/upnp:searchClass&gt;&lt;dc:title&gt;root&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;/DIDL-Lite&gt;</Result>
<NumberReturned>1</NumberReturned>
<TotalMatches>1</TotalMatches>
<UpdateID>2333</UpdateID></u:BrowseResponse></s:Body></s:Envelope>
07070100000055000081A40000000000000000000000016877CBDA00000378000000000000000000000000000000000000004E00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/action_Browse_metadata_2.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Result>&lt;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/"&gt;
&lt;container id="2" parentID="0" restricted="1" searchable="1" childCount="3"&gt;&lt;dc:title&gt;Video&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.storageFolder&lt;/upnp:class&gt;&lt;upnp:storageUsed&gt;-1&lt;/upnp:storageUsed&gt;&lt;/container&gt;&lt;/DIDL-Lite&gt;</Result>
<NumberReturned>1</NumberReturned>
<TotalMatches>1</TotalMatches>
<UpdateID>2333</UpdateID></u:BrowseResponse></s:Body></s:Envelope>
07070100000056000081A40000000000000000000000016877CBDA0000063A000000000000000000000000000000000000005100000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/action_Browse_metadata_item.xml<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Result>&lt;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/"&gt;
&lt;item id="1$6$35$1$1" parentID="1$6$35$1" restricted="1" refID="64$2$35$0$1"&gt;&lt;dc:title&gt;Test song&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;dc:creator&gt;Test creator&lt;/dc:creator&gt;&lt;dc:date&gt;1901-01-01&lt;/dc:date&gt;&lt;upnp:artist&gt;Test artist&lt;/upnp:artist&gt;&lt;upnp:album&gt;Test album&lt;/upnp:album&gt;&lt;upnp:genre&gt;Rock &amp;amp; Roll&lt;/upnp:genre&gt;&lt;upnp:originalTrackNumber&gt;2&lt;/upnp:originalTrackNumber&gt;&lt;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"&gt;http://dlna_dms:1234/media/2483.mp3&lt;/res&gt;&lt;upnp:albumArtURI dlna:profileID="JPEG_TN" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"&gt;http://dlna_dms:1234/art/238-2483.jpg&lt;/upnp:albumArtURI&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;</Result>
<NumberReturned>1</NumberReturned>
<TotalMatches>1</TotalMatches>
<UpdateID>2333</UpdateID></u:BrowseResponse></s:Body></s:Envelope>
07070100000057000081A40000000000000000000000016877CBDA00000877000000000000000000000000000000000000003C00000000async_upnp_client-0.45.0/tests/fixtures/dlna/dms/device.xml<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
    <friendlyName>Dummy server</friendlyName>
    <manufacturer>Steven</manufacturer>
    <modelDescription>Dummy media server</modelDescription>
    <modelName>Dummy Server</modelName>
    <modelNumber>1.3.0</modelNumber>
    <UDN>uuid:11111111-0000-0000-0000-000000000000</UDN>
    <dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMS-1.50</dlna:X_DLNADOC>
    <presentationURL>/</presentationURL>
    <iconList>
      <icon>
        <mimetype>image/png</mimetype>
        <width>48</width>
        <height>48</height>
        <depth>24</depth>
        <url>/icons/sm.png</url>
      </icon>
      <icon>
        <mimetype>image/png</mimetype>
        <width>120</width>
        <height>120</height>
        <depth>24</depth>
        <url>/icons/lrg.png</url>
      </icon>
      <icon>
        <mimetype>image/jpeg</mimetype>
        <width>48</width>
        <height>48</height>
        <depth>24</depth>
        <url>/icons/sm.jpg</url>
      </icon>
      <icon>
        <mimetype>image/jpeg</mimetype>
        <width>120</width>
        <height>120</height>
        <depth>24</depth>
        <url>/icons/lrg.jpg</url>
      </icon>
    </iconList>
    <serviceList>
      <service>
        <serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
        <controlURL>/upnp/control/ContentDir</controlURL>
        <eventSubURL>/upnp/event/ContentDir</eventSubURL>
        <SCPDURL>/ContentDirectory_1.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
        <controlURL>/upnp/control/ConnectionMgr</controlURL>
        <eventSubURL>/upnp/event/ConnectionMgr</eventSubURL>
        <SCPDURL>/ConnectionManager_1.xml</SCPDURL>
      </service>
    </serviceList>
  </device>
</root>
07070100000058000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002C00000000async_upnp_client-0.45.0/tests/fixtures/igd07070100000059000081A40000000000000000000000016877CBDA00000370000000000000000000000000000000000000004100000000async_upnp_client-0.45.0/tests/fixtures/igd/Layer3Forwarding.xml<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>SetDefaultConnectionService</name>
<argumentList>
<argument>
<name>NewDefaultConnectionService</name>
<direction>in</direction>
<relatedStateVariable>DefaultConnectionService</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetDefaultConnectionService</name>
<argumentList>
<argument>
<name>NewDefaultConnectionService</name>
<direction>out</direction>
<relatedStateVariable>DefaultConnectionService</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>DefaultConnectionService</name>
<dataType>string</dataType>
</stateVariable>
</serviceStateTable>
</scpd>
0707010000005A000081A40000000000000000000000016877CBDA00000EFC000000000000000000000000000000000000004900000000async_upnp_client-0.45.0/tests/fixtures/igd/WANCommonInterfaceConfig.xml<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>SetEnabledForInternet</name>
<argumentList>
<argument>
<name>NewEnabledForInternet</name>
<direction>in</direction>
<relatedStateVariable>EnabledForInternet</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetEnabledForInternet</name>
<argumentList>
<argument>
<name>NewEnabledForInternet</name>
<direction>out</direction>
<relatedStateVariable>EnabledForInternet</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCommonLinkProperties</name>
<argumentList>
<argument>
<name>NewWANAccessType</name>
<direction>out</direction>
<relatedStateVariable>WANAccessType</relatedStateVariable>
</argument>
<argument>
<name>NewLayer1UpstreamMaxBitRate</name>
<direction>out</direction>
<relatedStateVariable>Layer1UpstreamMaxBitRate</relatedStateVariable>
</argument>
<argument>
<name>NewLayer1DownstreamMaxBitRate</name>
<direction>out</direction>
<relatedStateVariable>Layer1DownstreamMaxBitRate</relatedStateVariable>
</argument>
<argument>
<name>NewPhysicalLinkStatus</name>
<direction>out</direction>
<relatedStateVariable>PhysicalLinkStatus</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetTotalBytesSent</name>
<argumentList>
<argument>
<name>NewTotalBytesSent</name>
<direction>out</direction>
<relatedStateVariable>TotalBytesSent</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetTotalBytesReceived</name>
<argumentList>
<argument>
<name>NewTotalBytesReceived</name>
<direction>out</direction>
<relatedStateVariable>TotalBytesReceived</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetTotalPacketsSent</name>
<argumentList>
<argument>
<name>NewTotalPacketsSent</name>
<direction>out</direction>
<relatedStateVariable>TotalPacketsSent</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetTotalPacketsReceived</name>
<argumentList>
<argument>
<name>NewTotalPacketsReceived</name>
<direction>out</direction>
<relatedStateVariable>TotalPacketsReceived</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>WANAccessType</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>DSL</allowedValue>
<allowedValue>POTS</allowedValue>
<allowedValue>Cable</allowedValue>
<allowedValue>Ethernet</allowedValue>
<allowedValue>Other</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>Layer1UpstreamMaxBitRate</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>Layer1DownstreamMaxBitRate</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>PhysicalLinkStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Up</allowedValue>
<allowedValue>Down</allowedValue>
<allowedValue>Initializing</allowedValue>
<allowedValue>Unavailable</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>TotalBytesSent</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>TotalBytesReceived</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>TotalPacketsSent</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>TotalPacketsReceived</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>EnabledForInternet</name>
<dataType>boolean</dataType>
</stateVariable>
</serviceStateTable>
</scpd>
0707010000005B000081A40000000000000000000000016877CBDA00002325000000000000000000000000000000000000004000000000async_upnp_client-0.45.0/tests/fixtures/igd/WANIPConnection.xml<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>SetConnectionType</name>
<argumentList>
<argument>
<name>NewConnectionType</name>
<direction>in</direction>
<relatedStateVariable>ConnectionType</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetConnectionTypeInfo</name>
<argumentList>
<argument>
<name>NewConnectionType</name>
<direction>out</direction>
<relatedStateVariable>ConnectionType</relatedStateVariable>
</argument>
<argument>
<name>NewPossibleConnectionTypes</name>
<direction>out</direction>
<relatedStateVariable>PossibleConnectionTypes</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>RequestConnection</name>
</action>
<action>
<name>ForceTermination</name>
</action>
<action>
<name>GetStatusInfo</name>
<argumentList>
<argument>
<name>NewConnectionStatus</name>
<direction>out</direction>
<relatedStateVariable>ConnectionStatus</relatedStateVariable>
</argument>
<argument>
<name>NewLastConnectionError</name>
<direction>out</direction>
<relatedStateVariable>LastConnectionError</relatedStateVariable>
</argument>
<argument>
<name>NewUptime</name>
<direction>out</direction>
<relatedStateVariable>Uptime</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetNATRSIPStatus</name>
<argumentList>
<argument>
<name>NewRSIPAvailable</name>
<direction>out</direction>
<relatedStateVariable>RSIPAvailable</relatedStateVariable>
</argument>
<argument>
<name>NewNATEnabled</name>
<direction>out</direction>
<relatedStateVariable>NATEnabled</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetGenericPortMappingEntry</name>
<argumentList>
<argument>
<name>NewPortMappingIndex</name>
<direction>in</direction>
<relatedStateVariable>PortMappingNumberOfEntries</relatedStateVariable>
</argument>
<argument>
<name>NewRemoteHost</name>
<direction>out</direction>
<relatedStateVariable>RemoteHost</relatedStateVariable>
</argument>
<argument>
<name>NewExternalPort</name>
<direction>out</direction>
<relatedStateVariable>ExternalPort</relatedStateVariable>
</argument>
<argument>
<name>NewProtocol</name>
<direction>out</direction>
<relatedStateVariable>PortMappingProtocol</relatedStateVariable>
</argument>
<argument>
<name>NewInternalPort</name>
<direction>out</direction>
<relatedStateVariable>InternalPort</relatedStateVariable>
</argument>
<argument>
<name>NewInternalClient</name>
<direction>out</direction>
<relatedStateVariable>InternalClient</relatedStateVariable>
</argument>
<argument>
<name>NewEnabled</name>
<direction>out</direction>
<relatedStateVariable>PortMappingEnabled</relatedStateVariable>
</argument>
<argument>
<name>NewPortMappingDescription</name>
<direction>out</direction>
<relatedStateVariable>PortMappingDescription</relatedStateVariable>
</argument>
<argument>
<name>NewLeaseDuration</name>
<direction>out</direction>
<relatedStateVariable>PortMappingLeaseDuration</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSpecificPortMappingEntry</name>
<argumentList>
<argument>
<name>NewRemoteHost</name>
<direction>in</direction>
<relatedStateVariable>RemoteHost</relatedStateVariable>
</argument>
<argument>
<name>NewExternalPort</name>
<direction>in</direction>
<relatedStateVariable>ExternalPort</relatedStateVariable>
</argument>
<argument>
<name>NewProtocol</name>
<direction>in</direction>
<relatedStateVariable>PortMappingProtocol</relatedStateVariable>
</argument>
<argument>
<name>NewInternalPort</name>
<direction>out</direction>
<relatedStateVariable>InternalPort</relatedStateVariable>
</argument>
<argument>
<name>NewInternalClient</name>
<direction>out</direction>
<relatedStateVariable>InternalClient</relatedStateVariable>
</argument>
<argument>
<name>NewEnabled</name>
<direction>out</direction>
<relatedStateVariable>PortMappingEnabled</relatedStateVariable>
</argument>
<argument>
<name>NewPortMappingDescription</name>
<direction>out</direction>
<relatedStateVariable>PortMappingDescription</relatedStateVariable>
</argument>
<argument>
<name>NewLeaseDuration</name>
<direction>out</direction>
<relatedStateVariable>PortMappingLeaseDuration</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>AddPortMapping</name>
<argumentList>
<argument>
<name>NewRemoteHost</name>
<direction>in</direction>
<relatedStateVariable>RemoteHost</relatedStateVariable>
</argument>
<argument>
<name>NewExternalPort</name>
<direction>in</direction>
<relatedStateVariable>ExternalPort</relatedStateVariable>
</argument>
<argument>
<name>NewProtocol</name>
<direction>in</direction>
<relatedStateVariable>PortMappingProtocol</relatedStateVariable>
</argument>
<argument>
<name>NewInternalPort</name>
<direction>in</direction>
<relatedStateVariable>InternalPort</relatedStateVariable>
</argument>
<argument>
<name>NewInternalClient</name>
<direction>in</direction>
<relatedStateVariable>InternalClient</relatedStateVariable>
</argument>
<argument>
<name>NewEnabled</name>
<direction>in</direction>
<relatedStateVariable>PortMappingEnabled</relatedStateVariable>
</argument>
<argument>
<name>NewPortMappingDescription</name>
<direction>in</direction>
<relatedStateVariable>PortMappingDescription</relatedStateVariable>
</argument>
<argument>
<name>NewLeaseDuration</name>
<direction>in</direction>
<relatedStateVariable>PortMappingLeaseDuration</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>DeletePortMapping</name>
<argumentList>
<argument>
<name>NewRemoteHost</name>
<direction>in</direction>
<relatedStateVariable>RemoteHost</relatedStateVariable>
</argument>
<argument>
<name>NewExternalPort</name>
<direction>in</direction>
<relatedStateVariable>ExternalPort</relatedStateVariable>
</argument>
<argument>
<name>NewProtocol</name>
<direction>in</direction>
<relatedStateVariable>PortMappingProtocol</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetExternalIPAddress</name>
<argumentList>
<argument>
<name>NewExternalIPAddress</name>
<direction>out</direction>
<relatedStateVariable>ExternalIPAddress</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>ConnectionType</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>PossibleConnectionTypes</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Unconfigured</allowedValue>
<allowedValue>IP_Routed</allowedValue>
<allowedValue>IP_Bridged</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ConnectionStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Unconfigured</allowedValue>
<allowedValue>Connected</allowedValue>
<allowedValue>Disconnected</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>Uptime</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>LastConnectionError</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>ERROR_NONE</allowedValue>
<allowedValue>ERROR_UNKNOWN</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>RSIPAvailable</name>
<dataType>boolean</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>NATEnabled</name>
<dataType>boolean</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ExternalIPAddress</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>PortMappingNumberOfEntries</name>
<dataType>ui2</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>PortMappingEnabled</name>
<dataType>boolean</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>PortMappingLeaseDuration</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>RemoteHost</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>ExternalPort</name>
<dataType>ui2</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>InternalPort</name>
<dataType>ui2</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>PortMappingProtocol</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>TCP</allowedValue>
<allowedValue>UDP</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>InternalClient</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>PortMappingDescription</name>
<dataType>string</dataType>
</stateVariable>
</serviceStateTable>
</scpd>
0707010000005C000081A40000000000000000000000016877CBDA000001A7000000000000000000000000000000000000005400000000async_upnp_client-0.45.0/tests/fixtures/igd/action_WANCIC_GetTotalBytesReceived.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
   <s:Body>
      <m:GetTotalBytesReceivedResponse xmlns:m="urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1">
         <NewTotalBytesReceived>1337</NewTotalBytesReceived>
      </m:GetTotalBytesReceivedResponse>
   </s:Body>
</s:Envelope>
0707010000005D000081A40000000000000000000000016877CBDA000001AD000000000000000000000000000000000000005700000000async_upnp_client-0.45.0/tests/fixtures/igd/action_WANCIC_GetTotalBytesReceived_i4.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
   <s:Body>
      <m:GetTotalBytesReceivedResponse xmlns:m="urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1">
         <NewTotalBytesReceived>-531985522</NewTotalBytesReceived>
      </m:GetTotalBytesReceivedResponse>
   </s:Body>
</s:Envelope>
0707010000005E000081A40000000000000000000000016877CBDA000001B7000000000000000000000000000000000000005600000000async_upnp_client-0.45.0/tests/fixtures/igd/action_WANCIC_GetTotalPacketsReceived.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
   <s:Body>
      <m:GetTotalPacketsReceivedResponse xmlns:m="urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1">
         <!--<NewTotalPacketsReceived>1337</NewTotalPacketsReceived> -->
      </m:GetTotalPacketsReceivedResponse>
   </s:Body>
</s:Envelope>
0707010000005F000081A40000000000000000000000016877CBDA00000205000000000000000000000000000000000000006200000000async_upnp_client-0.45.0/tests/fixtures/igd/action_WANIPConnection_GetStatusInfoInvalidUptime.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
   <s:Body>
      <m:GetStatusInfoResponse xmlns:m="urn:schemas-upnp-org:service:WANIPConnection:1">
         <NewConnectionStatus>Connected</NewConnectionStatus>
         <NewLastConnectionError>ERROR_NONE</NewLastConnectionError>
         <NewUptime>0 Days, 01:00:00</NewUptime>
      </m:GetStatusInfoResponse>
   </s:Body>
</s:Envelope>
07070100000060000081A40000000000000000000000016877CBDA00000168000000000000000000000000000000000000005A00000000async_upnp_client-0.45.0/tests/fixtures/igd/action_WANPIPConnection_DeletePortMapping.xml<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <u:DeletePortMappingResponse xmlns:u0="urn:schemas-upnp-org:service:WANIPConnection:1">
        </u:DeletePortMappingResponse>
    </s:Body>
</s:Envelope>
07070100000061000081A40000000000000000000000016877CBDA000009E1000000000000000000000000000000000000003700000000async_upnp_client-0.45.0/tests/fixtures/igd/device.xml<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
    <friendlyName>Dummy Router</friendlyName>
    <manufacturer>Steven</manufacturer>
    <modelDescription>Dummy Router IGD</modelDescription>
    <modelName>DummyRouter v1</modelName>
    <UDN>uuid:00000000-0000-0000-0000-000000000000</UDN>
    <serviceList>
      <service>
        <serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId>
        <SCPDURL>/Layer3Forwarding.xml</SCPDURL>
        <controlURL>/Layer3Forwarding</controlURL>
        <eventSubURL>/Layer3Forwarding</eventSubURL>
      </service>
    </serviceList>
    <deviceList>
      <device>
        <deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
        <friendlyName>WANDevice</friendlyName>
        <manufacturer>Steven</manufacturer>
        <modelDescription>WANDevice</modelDescription>
        <UDN>uuid:00000000-0000-0000-0000-000000000001</UDN>
        <serviceList>
          <service>
            <serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>
            <serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>
            <SCPDURL>/WANCommonInterfaceConfig.xml</SCPDURL>
            <controlURL>/WANCommonInterfaceConfig</controlURL>
            <eventSubURL>/WANCommonInterfaceConfig</eventSubURL>
          </service>
        </serviceList>
        <deviceList>
          <device>
            <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
            <friendlyName>WANConnectionDevice</friendlyName>
            <manufacturer>Steven</manufacturer>
            <modelDescription>WANConnectionDevice</modelDescription>
            <UDN>uuid:00000000-0000-0000-0000-000000000002</UDN>
            <serviceList>
              <service>
                <serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
                <SCPDURL>/WANIPConnection.xml</SCPDURL>
                <controlURL>/WANIPConnection</controlURL>
                <eventSubURL>/WANIPConnection</eventSubURL>
              </service>
            </serviceList>
          </device>
        </deviceList>
      </device>
    </deviceList>
  </device>
</root>
07070100000062000081A40000000000000000000000016877CBDA000003F0000000000000000000000000000000000000003400000000async_upnp_client-0.45.0/tests/fixtures/scpd_i8.xml<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
   <specVersion>
      <major>1</major>
      <minor>0</minor>
   </specVersion>
   <actionList>
      <action>
        <name>X_BigIntegers</name>
        <argumentList>
          <argument>
            <name>SignedEight</name>
            <direction>in</direction>
            <relatedStateVariable>A_ARG_TYPE_I</relatedStateVariable>
          </argument>
          <argument>
            <name>UnsignedEight</name>
            <direction>in</direction>
            <relatedStateVariable>A_ARG_TYPE_UI</relatedStateVariable>
          </argument>
        </argumentList>
      </action>
   </actionList>
   <serviceStateTable>
      <stateVariable sendEvents="no">
         <name>A_ARG_TYPE_I</name>
         <dataType>i8</dataType>
      </stateVariable>
      <stateVariable sendEvents="no">
         <name>A_ARG_TYPE_UI</name>
         <dataType>ui8</dataType>
      </stateVariable>
   </serviceStateTable>
</scpd>
07070100000063000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002F00000000async_upnp_client-0.45.0/tests/fixtures/server07070100000064000081A40000000000000000000000016877CBDA00000132000000000000000000000000000000000000004200000000async_upnp_client-0.45.0/tests/fixtures/server/action_request.xml<?xml version="1.0"?>
 <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
 <s:Body>
 <u:SetValues xmlns:u="urn:schemas-upnp-org:service:TestServerService:1">
 <In_Var1_str>foo</In_Var1_str>
 </u:SetValues>
 </s:Body>
 </s:Envelope>
07070100000065000081A40000000000000000000000016877CBDA0000018B000000000000000000000000000000000000004300000000async_upnp_client-0.45.0/tests/fixtures/server/action_response.xml<?xml version='1.0' encoding='utf-8'?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><st:SetValuesResponse xmlns:st="urn:schemas-upnp-org:service:TestServerService:1"><TestVariable_str>foo</TestVariable_str><EventableTextVariable_ui4>0</EventableTextVariable_ui4></st:SetValuesResponse></s:Body></s:Envelope>
07070100000066000081A40000000000000000000000016877CBDA000003CF000000000000000000000000000000000000003A00000000async_upnp_client-0.45.0/tests/fixtures/server/device.xml<?xml version='1.0' encoding='utf-8'?>
<root xmlns="urn:schemas-upnp-org:device-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><device xmlns="urn:schemas-upnp-org:device-1-0"><deviceType>:urn:schemas-upnp-org:device:TestServerDevice:1</deviceType><friendlyName>Test Server</friendlyName><manufacturer>Test</manufacturer><manufacturerURL /><modelDescription>Test Server</modelDescription><modelName>TestServer</modelName><modelNumber>v0.0.1</modelNumber><modelURL /><serialNumber>0000001</serialNumber><UDN>uuid:adca2e25-cbe4-427a-a5c3-9b5931e7b79b</UDN><UPC /><presentationURL /><iconList /><serviceList><service><serviceType>urn:schemas-upnp-org:service:TestServerService:1</serviceType><serviceId>urn:upnp-org:serviceId:TestServerService</serviceId><controlURL>/upnp/control/TestServerService</controlURL><eventSubURL>/upnp/event/TestServerService</eventSubURL><SCPDURL>/ContentDirectory.xml</SCPDURL></service></serviceList><deviceList /></device></root>
07070100000067000081A40000000000000000000000016877CBDA000000C1000000000000000000000000000000000000004800000000async_upnp_client-0.45.0/tests/fixtures/server/subscribe_response_0.xml<?xml version='1.0' encoding='utf-8'?>
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0"><e:property><EventableTextVariable_ui4>0</EventableTextVariable_ui4></e:property></e:propertyset>
07070100000068000081A40000000000000000000000016877CBDA000000C1000000000000000000000000000000000000004800000000async_upnp_client-0.45.0/tests/fixtures/server/subscribe_response_1.xml<?xml version='1.0' encoding='utf-8'?>
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0"><e:property><EventableTextVariable_ui4>1</EventableTextVariable_ui4></e:property></e:propertyset>
07070100000069000041ED0000000000000000000000026877CBDA00000000000000000000000000000000000000000000002800000000async_upnp_client-0.45.0/tests/profiles0707010000006A000081A40000000000000000000000016877CBDA00000037000000000000000000000000000000000000003400000000async_upnp_client-0.45.0/tests/profiles/__init__.py# -*- coding: utf-8 -*-
"""Unit tests for profiles."""
0707010000006B000081A40000000000000000000000016877CBDA000068FC000000000000000000000000000000000000003900000000async_upnp_client-0.45.0/tests/profiles/test_dlna_dmr.py"""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 = """
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
    <e:property>
        <LastChange>
            &lt;Event xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/AVT/&quot;&gt;
                &lt;InstanceID val=&quot;0&quot;&gt;
                    &lt;CurrentTransportActions val=&quot;{actions}&quot;/&gt;
                    &lt;/InstanceID&gt;
            &lt;/Event&gt;
        </LastChange>
    </e:property>
</e:propertyset>
"""


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 = """<Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/">
<InstanceID val="0"><TransportState val="PAUSED_PLAYBACK"/></InstanceID>
</Event>"""
    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 = """<Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/">
<InstanceID val="0"><TransportState val="PAUSED_PLAYBACK"/></InstanceID>
<InstanceID val="1"><TransportState val="PLAYING"/></InstanceID>
</Event>"""
    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 = """<Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/">
<InstanceID val="0">
  <Volume channel="Master" val="10"/>
  <Volume channel="Left" val="20"/>
  <Volume channel="Right" val="30"/>
</InstanceID>
</Event>"""
    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 = """<Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/">
<InstanceID val="0"><TransportState val="PAUSED_PLAYBACK"></InstanceID>
</Event>"""
    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 = """
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
    <e:property>
        <LastChange>
            &lt;Event xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/RCS/&quot;&gt;
                &lt;InstanceID val=&quot;0&quot;&gt;
                    &lt;Mute channel=&quot;Master&quot; val=&quot;0&quot;/&gt;
                    &lt;Volume channel=&quot;Master&quot; val=&quot;50&quot;/&gt;
                    &lt;/InstanceID&gt;
            &lt;/Event&gt;
        </LastChange>
    </e:property>
</e:propertyset>
"""

    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(
        """<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
 xmlns:dc="http://purl.org/dc/elements/1.1/"
 xmlns:sec="http://www.sec.co.kr/"
 xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="false">
<dc:title>Test music</dc:title>
<upnp:class>object.item</upnp:class>
<res protocolInfo="http-get:*:application/octet-stream:*">
http://dlna_dms:4321/object/file_1222
</res>
</item>
</DIDL-Lite>""".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:*"
0707010000006C000081A40000000000000000000000016877CBDA0000194D000000000000000000000000000000000000003900000000async_upnp_client-0.45.0/tests/profiles/test_dlna_dms.py"""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
0707010000006D000081A40000000000000000000000016877CBDA00001135000000000000000000000000000000000000003400000000async_upnp_client-0.45.0/tests/profiles/test_igd.py"""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
0707010000006E000081A40000000000000000000000016877CBDA00005D39000000000000000000000000000000000000003800000000async_upnp_client-0.45.0/tests/profiles/test_profile.py"""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"
0707010000006F000081A40000000000000000000000016877CBDA00000C78000000000000000000000000000000000000003500000000async_upnp_client-0.45.0/tests/test_advertisement.py"""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()
07070100000070000081A40000000000000000000000016877CBDA000009C5000000000000000000000000000000000000002F00000000async_upnp_client-0.45.0/tests/test_aiohttp.py"""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)
07070100000071000081A40000000000000000000000016877CBDA00007D65000000000000000000000000000000000000002E00000000async_upnp_client-0.45.0/tests/test_client.py# -*- 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
        assert state_var.step_value == 1

        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/step are set
        assert state_var.min_value == 0
        assert state_var.max_value == 100
        assert state_var.step_value == 1

        # 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 = "<item>test thing</item>"
        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)
            == "<item>test thing</item>"
        )

        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": "<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>",
            "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"]
07070100000072000081A40000000000000000000000016877CBDA000010A4000000000000000000000000000000000000003900000000async_upnp_client-0.45.0/tests/test_description_cache.py"""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 = """<root xmlns="urn:schemas-upnp-org:device-1-0">
  <device>
    <deviceType>urn:schemas-upnp-org:device:TestDevice:1</deviceType>
    <UDN>uuid:test_udn</UDN>
  </device>
</root>"""
    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 = """<root xmlns="urn:schemas-upnp-org:device-1-0">
  <device>
    <deviceType>urn:schemas-upnp-org:device:TestDevice:1</deviceType>
    <UDN>uuid:test_udn</UDN>
    <serialNumber>\xff\xff\xff\xff</serialNumber>
  </device>
</root>"""
    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 = """<root xmlns="urn:schemas-upnp-org:device-1-0">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
07070100000073000081A40000000000000000000000016877CBDA00001D8B000000000000000000000000000000000000003500000000async_upnp_client-0.45.0/tests/test_event_handler.py# -*- 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 = """
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
    <e:property>
        <Volume>60</Volume>
    </e:property>
</e:propertyset>
"""

    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
07070100000074000081A40000000000000000000000016877CBDA000007FE000000000000000000000000000000000000002E00000000async_upnp_client-0.45.0/tests/test_search.py"""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()
07070100000075000081A40000000000000000000000016877CBDA00002D6D000000000000000000000000000000000000002E00000000async_upnp_client-0.45.0/tests/test_server.py"""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 aiohttp.test_utils import TestClient
from aiohttp.web import Application, Request
from pytest_aiohttp.plugin import AiohttpClient

import async_upnp_client.aiohttp
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: TestClient[Request, Application] | None = None
        self.app = aiohttp.web.Application()
        self.app.router.add_route("NOTIFY", "/{tail:.*}", self.handler)

    async def start(self, aiohttp_client: AiohttpClient) -> 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: TestClient
    ssdp_sockets: List[socket.socket]
    callback: Callback
    server: UpnpServer


@pytest_asyncio.fixture
async def upnp_server(
    monkeypatch: Any, aiohttp_client: AiohttpClient
) -> AsyncGenerator[UpnpServerTuple, None]:
    """Fixture to initialize device."""
    # pylint: disable=too-few-public-methods

    ssdp_sockets: List[socket.socket] = []
    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 = 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 http_client
    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": "</foo/bar>", "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()
07070100000076000081A40000000000000000000000016877CBDA000027F2000000000000000000000000000000000000002C00000000async_upnp_client-0.45.0/tests/test_ssdp.py"""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"
        b"\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"
        b"\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"
        b"\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"
        b"\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"
        b"\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"
        b"\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)
07070100000077000081A40000000000000000000000016877CBDA0000683F000000000000000000000000000000000000003500000000async_upnp_client-0.45.0/tests/test_ssdp_listener.py"""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
07070100000078000081A40000000000000000000000016877CBDA000010B4000000000000000000000000000000000000002D00000000async_upnp_client-0.45.0/tests/test_utils.py"""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
07070100000079000081A40000000000000000000000016877CBDA0000009B000000000000000000000000000000000000002800000000async_upnp_client-0.45.0/towncrier.toml[tool.towncrier]
name = "async_upnp_client"
package = "async_upnp_client"
package_dir = "async_upnp_client"
directory = "changes"
filename = "CHANGES.rst"
0707010000007A000081A40000000000000000000000016877CBDA000006B1000000000000000000000000000000000000002100000000async_upnp_client-0.45.0/tox.ini[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.12.14
    pytest-asyncio >= 0.24,< 0.27
    pytest-aiohttp >=1.0.5,<1.2.0
    pytest-cov >= 5.0,< 6.2
    coverage >= 7.6.1,< 7.9.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.27
    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.12.14
    pytest-asyncio >= 0.24,< 0.27
    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
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!1491 blocks
openSUSE Build Service is sponsored by