pax_global_header00006660000000000000000000000064137634261220014520gustar00rootroot0000000000000052 comment=9f67a5f9fba13a7918418cf2e1c53f61dac6ee6e ssdpy-0.4.1/000077500000000000000000000000001376342612200126645ustar00rootroot00000000000000ssdpy-0.4.1/.coveragerc000066400000000000000000000003131376342612200150020ustar00rootroot00000000000000[run] branch = True source = ssdpy [report] exclude_lines = if self.debug: pragma: no cover raise NotImplementedError if __name__ == .__main__.: ignore_errors = True omit = tests/* ssdpy-0.4.1/.editorconfig000066400000000000000000000004431376342612200153420ustar00rootroot00000000000000# https://editorconfig.org/ # top-most EditorConfig file root = true [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 trim_trailing_whitespace = true charset = utf-8 [*.py] max_line_length = 119 [*.yml] indent_size = 2 [Makefile] indent_style = tab ssdpy-0.4.1/.github/000077500000000000000000000000001376342612200142245ustar00rootroot00000000000000ssdpy-0.4.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001376342612200164075ustar00rootroot00000000000000ssdpy-0.4.1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012451376342612200211030ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: MoshiBin --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - Python version: [e.g. 2.7, 3.8] - SSDPy version: [e.g. 0.2.1, 0.3.0] **Additional context** Add any other context about the problem here. ssdpy-0.4.1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011421376342612200221320ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: MoshiBin --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ssdpy-0.4.1/.github/workflows/000077500000000000000000000000001376342612200162615ustar00rootroot00000000000000ssdpy-0.4.1/.github/workflows/build.yml000066400000000000000000000037621376342612200201130ustar00rootroot00000000000000name: Build on: push: branches: [ master ] pull_request: branches: [ master ] workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: 3.8 - run: | python -m pip install --upgrade pip setuptools wheel python -m pip install -e . - run: | python -m pip install flake8 flake8 --ignore=E501 . - run: python setup.py sdist bdist_wheel docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: 3.8 - run: | python -m pip install --upgrade pip setuptools python -m pip install -r dev-requirements.txt - run: | cd docs make linkcheck make html pytest: # The type of runner that the job will run on runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 # Runs a single command using the runners shell - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Display Python version run: python -c "import sys; print(sys.version)" - name: Install SSDPy run: | pip install --upgrade pip pip install --upgrade mock pytest pytest-mock pytest-cov codecov pip install --editable . - name: pytest run: sudo -E $pythonLocation/bin/coverage run --source ssdpy,tests -m pytest tests/ - name: Upload to codecov uses: codecov/codecov-action@v1 with: fail_ci_if_error: true ssdpy-0.4.1/.gitignore000066400000000000000000000032741376342612200146620ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ ssdpy-0.4.1/CHANGES.md000066400000000000000000000036771376342612200142730ustar00rootroot00000000000000### Latest changes (not in a release) ### 0.4.1 (2020-12-07) - SSDPy no longer requires the `mock` package, other than for testing. The package now only relies on builtin libraries. - Fixed an issue with custom fields being encoded to bytes instead of being passed as strings. ### 0.4.0 (2020-12-06) - Fixed an issue where NOTIFY messages were not conforming to UPnP (thanks @hotab) - Fixed an issue where `ssdpy.client.discover()` was using wrong syntax. - Changed the exception raised by `ssdpy.compat.if_nametoindex()` to be the same as in Python 3 (OSError). - Added tests for `ssdpy.client`, `ssdpy.compat` and created more tests for `ssdpy.server` to increase coverage. - Added support for custom fields in NOTIFY. Pass `extra_fields={"field": "value"}` to `ssdpy.SSDPServer` or pass `-e|--extra-field NAME VALUE` to `ssdpy-server`. ### 0.3.0 (2020-08-10) - Dropped support for Python 3.4 - Fixed a compatibility issue in protocol.py (thanks @ZacJW) ### 0.2.3 (2019-11-28) - Added `--json` flag to ssdpy-discover. - `SSDPServer.serve_forever()` will skip packets it cannot send instead of crashing. ### 0.2.2 (2019-11-14) - Fixed a dependency issue with mock on Python 2.7. ### 0.2.1 (2019-11-13) - Added `--address` to ssdpy-server. - Binding to an interface now explicitly subscribes the interface to the multicast group. - Removed `constants.IPv4` and `constants.IPv6` in favor of raw strings: `("ipv4", "ipv6")`. - Added code coverage reports. - Added tests for ssdpy-server. - Increase testing breadth to include more python versions (2.7, >=3.4). ### 0.2.0 (2019-11-07) - Added ssdpy-server CLI command. - Added ssdpy-discover CLI command. - Internal: Added helper scripts to manage releases. - Internal: Added python package metadata. ### 0.1.2 (2019-11-03) - Quoted the `MAN` field in `M-SEARCH` to be compatible with current implementations of SSDP. ### 0.1.1 (2019-11-02) - Added `dev-requirements.txt`. ### 0.1.0 (2019-11-02) - Initial release. ssdpy-0.4.1/CONTRIBUTING.md000066400000000000000000000023001376342612200151100ustar00rootroot00000000000000# How to contribute :tada: Thank you for taking the time to contribute to SSDPy! :+1: Following are some general guidelines to ensure your contribution is as effective as possible. ## Did you find a bug? * Ensure the bug was not already reported by searching [existing issues](https://github.com/MoshiBin/ssdpy/issues?q=is%3Aissue). * If no such issue exists, [open a new issue](https://github.com/MoshiBin/ssdpy/issues/new/choose). Please set a clear title and description, add as much relevant information as possible. Preferably you should include a code sample to help reproduce the issue. * If SSDPy is behaving in a way that does not conform to the IETF or UPnP specifications, please include the relevant section from the spec. ## Did you write a patch to fix a bug? * Open a [new Pull Request](https://github.com/MoshiBin/ssdpy/compare) with the patch. * Ensure your pull request has a clear title and description that describes the problem and the solution. * Include a reference to an issue, if applicable. ## Do you intend to add a new feature or change an existing one? * Feel free to open an issue with the "enhancement" label. We will discuss the feature in the issue page. Thank you! :heart: ssdpy-0.4.1/LICENSE000066400000000000000000000020611376342612200136700ustar00rootroot00000000000000MIT License Copyright (c) 2019 Moshi Binyamini Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ssdpy-0.4.1/Makefile000066400000000000000000000003351376342612200143250ustar00rootroot00000000000000.PHONY: clean clean: rm -rf dist \ ssdpy/__pycache__ \ ssdpy/*.pyc \ ssdpy.egg-info \ tests/*.pyc \ build .PHONY: release release: clean python setup.py sdist bdist_wheel twine upload dist/* ssdpy-0.4.1/README.md000066400000000000000000000057611376342612200141540ustar00rootroot00000000000000# SSDPy: Python SSDP library ![Build](https://github.com/MoshiBin/ssdpy/workflows/Build/badge.svg) [![PyPI](https://img.shields.io/pypi/v/ssdpy)](https://pypi.org/project/ssdpy/) [![PyPI - Wheel](https://img.shields.io/pypi/wheel/ssdpy)](https://pypi.org/project/ssdpy/) ![GitHub](https://img.shields.io/github/license/MoshiBin/ssdpy) [![codecov](https://codecov.io/gh/MoshiBin/ssdpy/branch/master/graph/badge.svg)](https://codecov.io/gh/MoshiBin/ssdpy) [![Read the Docs](https://img.shields.io/readthedocs/ssdpy)](https://ssdpy.readthedocs.io/en/latest/) SSDPy is a lightweight implementation of [SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) (Simple Service Discovery Protocol). It is designed for ease of use and high compatibility with the protocol in real-life use. It supports both the IETF and UPnP versions of the protocol. You can read the [full documentation here](https://ssdpy.readthedocs.io/en/latest/). ## Example usage Send an SSDP discover packet (M-SEARCH): ```python >>> from ssdpy import SSDPClient >>> client = SSDPClient() >>> devices = client.m_search("ssdp:all") >>> for device in devices: ... print(device.get("usn")) uuid:Dell-Printer-1_0-dsi-secretariat::urn:schemas-upnp-org:service:PrintBasic:1 uuid:00000000-0000-0000-0200-00125A8A0960::urn:schemas-microsoft-com:nhed:presence:1 ``` Send an SSDP NOTIFY packet, telling others about a service: ```python >>> from ssdpy import SSDPServer >>> server = SSDPServer("my-service-identifier") >>> server.notify() ``` Start an SSDP server which responds to relevant M-SEARCHes: ```python >>> from ssdpy import SSDPServer >>> server = SSDPServer("my-service-identifier", device_type="my-device-type") >>> server.serve_forever() ``` Then, from a client, M-SEARCH for our server: ```python >>> from ssdpy import SSDPClient >>> client = SSDPClient() >>> devices = client.m_search("my-device-type") >>> for device in devices: ... print(device.get("usn")) my-service-identifier ``` ## CLI utilities SSDPy comes with two CLI utilities: - ssdpy-server is a server that listens for M-SEARCHes and responds if they match its name. - ssdpy-discover sends an M-SEARCH query and collects all responses. ## Release checklist - Update `ssdpy/version.py` with new version name. - Update `CHANGES.md`. - Commit the changes, tag with version & push. - Run `make release`. ## Links * IETF draft of the protocl (still in use by some devices, e.g. redfish) [https://tools.ietf.org/html/draft-cai-ssdp-v1-03](https://tools.ietf.org/html/draft-cai-ssdp-v1-03) * UPnP Device Architecture 1.1 [https://web.archive.org/web/20150905102426/http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.1.pdf](https://web.archive.org/web/20150905102426/http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.1.pdf) * UPnP Device Architecture 2.0 [https://web.archive.org/web/20151107123618/http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v2.0.pdf](https://web.archive.org/web/20151107123618/http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v2.0.pdf) ssdpy-0.4.1/dev-requirements.txt000066400000000000000000000001051376342612200167200ustar00rootroot00000000000000mock pytest wheel twine pytest-cov pytest-mock codecov sphinx==3.2.0 ssdpy-0.4.1/docs/000077500000000000000000000000001376342612200136145ustar00rootroot00000000000000ssdpy-0.4.1/docs/Makefile000066400000000000000000000011721376342612200152550ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ssdpy-0.4.1/docs/api.rst000066400000000000000000000006501376342612200151200ustar00rootroot00000000000000=== API === .. module:: ssdpy This part of the documentation lists the full API reference of all public classes and functions. Classes ======= .. autoclass:: SSDPClient :members: .. autoclass:: SSDPServer :members: Functions ========= .. autofunction:: ssdpy.client::discover Helpers ======= .. autofunction:: ssdpy.protocol::create_msearch_payload .. autofunction:: ssdpy.protocol::create_notify_payload ssdpy-0.4.1/docs/cli.rst000066400000000000000000000007711376342612200151220ustar00rootroot00000000000000.. currentmodule:: ssdpy ====================== Command Line Interface ====================== SSDPy bundles two scripts - ``ssdpy-server`` and ``ssdpy-discover``. These can be used to interact with SSDP without writing a single line of code. SSDP Discovery ============== The ``ssdpy-discover`` command sends SSDP ``DISCOVER`` packets, listens for responses, and prints the result. To search for all available services, use the ``ssdp:all`` target: .. code-block:: sh $ ssdpy-discover ssdp:all ssdpy-0.4.1/docs/conf.py000066400000000000000000000042741376342612200151220ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- project = "SSDPy" copyright = "2020, Moshi Binyamini" author = "Moshi Binyamini" # The full version, including alpha/beta/rc tags release = "0.3.0" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "alabaster" html_theme_options = { "description": "SSDP in Python", "github_user": "MoshiBin", "github_repo": "ssdpy", "github_button": True, "codecov_button": True, "show_related": True, } html_sidebars = { "**": ["about.html", "navigation.html", "searchbox.html"] } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] ssdpy-0.4.1/docs/index.rst000066400000000000000000000007161376342612200154610ustar00rootroot00000000000000Welcome to SSDPy's documentation! ================================= SSDPy is a lightweight implementation of SSDP in Python. It provides a Python library and a couple of CLI tools to interact with SSDP. It supports both the IETF and UPnP versions of the protocol. .. toctree:: :maxdepth: 2 usage/installation.rst usage/quickstart.rst cli.rst api.rst Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ssdpy-0.4.1/docs/make.bat000066400000000000000000000014331376342612200152220ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ssdpy-0.4.1/docs/usage/000077500000000000000000000000001376342612200147205ustar00rootroot00000000000000ssdpy-0.4.1/docs/usage/installation.rst000066400000000000000000000021521376342612200201530ustar00rootroot00000000000000Installation ============ Python Version -------------- The latest Python 3 version is always recommended, since it has all the latest bells and whistles. SSDPy supports Python 3.5 and above, and also Python 2.7 for compatibility with legacy projects. Dependencies ------------ SSDPy only uses packages from the standard library, so no additional dependencies will be installed when installing SSDPy. We aim to be as lightweight as possible, so even `six`_ is not required. .. _six: https://six.readthedocs.io/ Install SSDPy ------------- SSDPy is available on `PyPI`_, and can be installed using pip. The version on PyPI is always the latest stable release. .. _PyPi: https://pypi.org/project/ssdpy/ .. code-block:: sh $ pip install ssdpy Installing bleeding edge version ******************************** If you want to work with the latest SSDPy code before it's released, install directly from the master branch. The master branch undergoes constant testing to verify some level of stability, but issues may happen. .. code-block:: sh $ pip install -U https://github.com/MoshiBin/ssdpy/archive/master.zip ssdpy-0.4.1/docs/usage/quickstart.rst000066400000000000000000000027021376342612200176450ustar00rootroot00000000000000========== Quickstart ========== Run SSDP discovery from shell ============================= .. note:: SSDP works by sending and receiving multicasts. This sometimes requires elevated permissions. If you get an error trying to use these commands, try running them as ``root`` (for example, using ``sudo``). Discover services using SSDP ---------------------------- Searching for the special service type ``ssdp:all`` should return answers from all active services. .. code-block:: sh $ ssdpy-discover ssdp:all Discover a specific type of service ----------------------------------- Specify a different service type to only get responses from relevant services. For example, if we want to find all `DIAL`_ services (e.g. Chromecast devices): .. code-block:: sh $ ssdpy-discover urn:dial-multiscreen-org:service:dial:1 .. _DIAL: http://www.dial-multiscreen.org/ Run SSDP Server from shell ========================== .. code-block:: sh $ ssdpy-server my-special-service --location 'http://10.0.0.1:8080/hello' SSDP Discovery from Python ========================== :class:`ssdpy.SSDPClient` .. code-block:: python from ssdpy import SSDPClient client = SSDPClient() SSDP Server from Python ======================= :class:`ssdpy.SSDPServer` .. code-block:: python from ssdpy import SSDPServer server = SSDPServer("my-special-service", location="http://192.168.0.100:8080/hello") server.server_forever() ssdpy-0.4.1/pyproject.toml000066400000000000000000000001541376342612200156000ustar00rootroot00000000000000[build-system] requires = ["setuptools", "wheel"] # PEP 508 specifications. [tool.black] line-length = 119ssdpy-0.4.1/setup.cfg000066400000000000000000000000701376342612200145020ustar00rootroot00000000000000[bdist_wheel] universal = 1 [doc8] max-line-length=119 ssdpy-0.4.1/setup.py000066400000000000000000000027121376342612200144000ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup, find_packages from ssdpy.version import VERSION with open("README.md", "r") as fh: long_description = fh.read() setup( name="ssdpy", version=VERSION, long_description=long_description, long_description_content_type="text/markdown", description="Python SSDP library", license="MIT", author="Moshi Binyamini", author_email="moshi@moshib.in", url="https://github.com/MoshiBin/ssdpy", packages=find_packages(exclude=["tests"]), python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<4", entry_points={ "console_scripts": [ "ssdpy-server = ssdpy.cli.server:main", "ssdpy-discover = ssdpy.cli.client:main", ] }, classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: System :: Networking", "Topic :: Software Development :: Libraries :: Python Modules", ], ) ssdpy-0.4.1/ssdpy/000077500000000000000000000000001376342612200140265ustar00rootroot00000000000000ssdpy-0.4.1/ssdpy/__init__.py000066400000000000000000000004061376342612200161370ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals from .version import VERSION from .server import SSDPServer from .client import SSDPClient __version__ = VERSION __all__ = ["SSDPServer", "SSDPClient"] ssdpy-0.4.1/ssdpy/cli/000077500000000000000000000000001376342612200145755ustar00rootroot00000000000000ssdpy-0.4.1/ssdpy/cli/__init__.py000066400000000000000000000000001376342612200166740ustar00rootroot00000000000000ssdpy-0.4.1/ssdpy/cli/client.py000066400000000000000000000044261376342612200164330ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import argparse import json import logging import pprint from ..version import VERSION from ..client import SSDPClient logging.basicConfig() def parse_args(argv): parser = argparse.ArgumentParser(description="Run an SSDP M-SEARCH",) parser.add_argument( "-V", "--version", action="version", version="%(prog)s {}".format(VERSION) ) parser.add_argument("-v", "--verbose", help="Be more verbose", action="store_true") parser.add_argument( "-6", "--ipv6", help="Listen on IPv6 instead of IPv4", action="store_true" ) parser.add_argument( "-t", "--ttl", help="TTL for the M-SEARCH (default: 2)", default=2, type=int ) parser.add_argument( "-o", "--timeout", help="Maximum timeout for connections (default: 5)", default=5, type=int, ) parser.add_argument("ST", help="Type of device to search for (ST)", nargs=1) parser.add_argument("-i", "--iface", help="Listen on a specific network interface") parser.add_argument("-a", "--address", help="Bind to this address") parser.add_argument( "-p", "--port", help="Send on this port (default: 1900)", default=1900, type=int ) parser.add_argument( "-m", "--mx", help="Maximum wait time for response (default 1s)", default=1, type=int, ) parser.add_argument( "-j", "--json", help="Format output as JSON", action="store_true", ) return parser.parse_args(argv) def main(argv=None): args = parse_args(argv) if args.ipv6: proto = "ipv6" else: proto = "ipv4" if args.iface is not None: args.iface = args.iface.encode("utf-8") client = SSDPClient( proto=proto, port=args.port, ttl=args.ttl, iface=args.iface, timeout=args.timeout, address=args.address, ) logger = logging.getLogger("ssdpy.client") logger.setLevel(logging.INFO) if args.verbose: logger.setLevel(logging.DEBUG) response = client.m_search(st=args.ST[0], mx=args.mx) if args.json: print(json.dumps(response)) else: for device in response: pprint.pprint(device) ssdpy-0.4.1/ssdpy/cli/server.py000066400000000000000000000055671376342612200164720ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import argparse import logging from ..version import VERSION from ..server import SSDPServer logging.basicConfig() def parse_args(argv): parser = argparse.ArgumentParser(description="Start an SSDP server") parser.add_argument("-V", "--version", action="version", version="%(prog)s {}".format(VERSION)) parser.add_argument("-v", "--verbose", help="Be more verbose", action="store_true") proto_group = parser.add_mutually_exclusive_group() proto_group.add_argument("-4", "--ipv4", help="Listen on IPv4 (default: True)", action="store_true") proto_group.add_argument("-6", "--ipv6", help="Listen on IPv6 instead of IPv4", action="store_true") parser.add_argument("usn", help="Unique server name", nargs=1) parser.add_argument( "-t", "--device-type", help="Device type. Affects the NT field (default: ssdp:rootdevice)", default="ssdp:rootdevice", ) parser.add_argument("-i", "--iface", help="Listen on a specific network interface") parser.add_argument( "-p", "--port", help="Listen on this port (default: 1900)", default=1900, type=int, ) parser.add_argument( "--max-age", help="The amount of seconds that the server info should be cached for (default: do not cache)", type=int, ) parser.add_argument( "-l", "--location", help="Location that notifications should point to. This sets both LOCATION and AL", ) parser.add_argument( "-a", "--address", help="Address of the interface to listen on. Only valid for IPv4.", ) parser.add_argument( "-e", "--extra-field", action="append", nargs=2, metavar=("NAME", "VALUE"), help="Extra fields to pass in NOTIFY packets. Pass multiple times for multiple extra headers", ) return parser.parse_args(argv) def main(argv=None): args = parse_args(argv) extra_fields = None if args.extra_field is not None: extra_fields = dict(args.extra_field) if args.ipv6: proto = "ipv6" else: proto = "ipv4" if args.iface is not None: args.iface = args.iface.encode("utf-8") server = SSDPServer( args.usn[0], proto=proto, device_type=args.device_type, port=args.port, iface=args.iface, address=args.address, max_age=args.max_age, al=args.location, location=args.location, extra_fields=extra_fields, ) logger = logging.getLogger("ssdpy.server") logger.setLevel(logging.INFO) if args.verbose: logger.setLevel(logging.DEBUG) try: server.serve_forever() except KeyboardInterrupt: logger.error("Keyboard interrupt received, shutting down") ssdpy-0.4.1/ssdpy/client.py000066400000000000000000000076031376342612200156640ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import socket from .constants import ipv4_multicast_ip, ipv6_multicast_ip from .http_helper import parse_headers from .protocol import create_msearch_payload from .compat import if_nametoindex, SO_BINDTODEVICE class SSDPClient(object): def __init__( self, proto="ipv4", port=1900, ttl=2, iface=None, timeout=5, address=None, *args, **kwargs ): allowed_protos = ("ipv4", "ipv6") if proto not in allowed_protos: raise ValueError( "Invalid proto - expected one of {}".format(allowed_protos) ) self.port = port if proto == "ipv4": af_type = socket.AF_INET self.broadcast_ip = ipv4_multicast_ip self._address = (self.broadcast_ip, port) elif proto == "ipv6": af_type = socket.AF_INET6 self.broadcast_ip = ipv6_multicast_ip # TODO: Support other ipv6 multicasts self._address = (self.broadcast_ip, port, 0, 0) self.sock = socket.socket(af_type, socket.SOCK_DGRAM, socket.IPPROTO_UDP) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) self.sock.settimeout(timeout) if address is not None: self.sock.bind((address, 0)) if iface is not None: self.sock.setsockopt(socket.SOL_SOCKET, SO_BINDTODEVICE, iface) if proto == "ipv6": # Specifically set multicast on interface iface_index = if_nametoindex(iface) self.sock.setsockopt( socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_index ) def send(self, data): self.sock.sendto(data, self._address) def recv(self): try: while True: data = self.sock.recv(1024) yield data except socket.timeout: pass return def m_search(self, st="ssdp:all", mx=1): """ Send an M-SEARCH request and gather responses. :param st: The Search Target, used to narrow down the responses that should be received. Defaults to "ssdp:all" which should get responses from any SSDP-enabled device. :type st: str :param mx: Maximum wait time (in seconds) that devices are allowed to wait before sending a response. Should be between 1 and 5, though this is not enforced in this implementation. Devices will randomly wait for anywhere between 0 and 'mx' seconds in order to avoid flooding the client that sends the M-SEARCH. Increase the value of 'mx' if you expect a large number of devices to answer, in order to avoid losing responses. :type mx: int :return: A list of all discovered SSDP services. Each service is represented by a dict, with the keys being the lowercase equivalents of the response headers. """ host = "{}:{}".format(self.broadcast_ip, self.port) data = create_msearch_payload(host, st, mx) self.send(data) responses = [x for x in self.recv()] parsed_responses = [] for response in responses: try: headers = parse_headers(response) parsed_responses.append(headers) except ValueError: # Invalid response, do nothing. # TODO: Log dropped responses pass return parsed_responses def discover(): """ An ad-hoc way of discovering all SSDP services without explicitly initializing an :class:`~ssdpy.SSDPClient`. :return: A list of all discovered SSDP services, each service in a dictionary. """ return SSDPClient().m_search() ssdpy-0.4.1/ssdpy/compat.py000066400000000000000000000016251376342612200156670ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import socket import sys PY2 = sys.version_info[0] == 2 string_types = basestring if PY2 else str # noqa # Python 2 doesn't have socket.if_nametoindex so we need to implement it manually if PY2: import ctypes import ctypes.util libc = ctypes.CDLL(ctypes.util.find_library('c')) def if_nametoindex(name): """ Return the logical index number of the given interface name. """ if not isinstance(name, string_types): raise TypeError("Expected string type, got '{}'".format(type(name))) rc = libc.if_nametoindex(name) if rc == 0: raise OSError("no interface with this name '{}'".format(name)) return rc else: if_nametoindex = socket.if_nametoindex SO_BINDTODEVICE = getattr(socket, "SO_BINDTODEVICE", 25) ssdpy-0.4.1/ssdpy/constants.py000066400000000000000000000002611376342612200164130ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals ipv4_multicast_ip = "239.255.255.250" ipv6_multicast_ip = "ff02::c" ssdpy-0.4.1/ssdpy/http_helper.py000066400000000000000000000022771376342612200167260ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals def parse_headers(response, convert_to_lowercase=True): """ Receives an HTTP response/request bytes object and parses the HTTP headers. Return a dict of all headers. If convert_to_lowercase is true, all headers will be saved in lowercase form. """ valid_headers = ( b"NOTIFY * HTTP/1.1\r\n", b"M-SEARCH * HTTP/1.1\r\n", b"HTTP/1.1 200 OK\r\n", ) if not any([response.startswith(x) for x in valid_headers]): raise ValueError( "Invalid header: Should start with one of: {}".format(valid_headers) ) lines = response.split(b"\r\n") headers = {} # Skip the first line since it's just the HTTP return code for line in lines[1:]: if not line: break # Headers and content are separated by a blank line if b":" not in line: raise ValueError("Invalid header: {}".format(line)) header_name, header_value = line.split(b":", 1) headers[header_name.decode("utf-8").lower().strip()] = header_value.decode( "utf-8" ).strip() return headers ssdpy-0.4.1/ssdpy/protocol.py000066400000000000000000000072771376342612200162560ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals def create_msearch_payload(host, st, mx=1): """ Create an M-SEARCH packet using the given parameters. Returns a bytes object containing a valid M-SEARCH request. :param host: The address (IP + port) that the M-SEARCH will be sent to. This is usually a multicast address. :type host: str :param st: Search target. The type of services that should respond to the search. :type st: str :param mx: Maximum wait time, in seconds, for responses. :type mx: int :return: A bytes object containing the generated M-SEARCH payload. """ data = ( "M-SEARCH * HTTP/1.1\r\n" "HOST:{}\r\n" 'MAN: "ssdp:discover"\r\n' "ST:{}\r\n" "MX:{}\r\n" "\r\n" ).format(host, st, mx) return data.encode("utf-8") def create_notify_payload(host, nt, usn, location=None, al=None, max_age=None, extra_fields=None): """ Create a NOTIFY packet using the given parameters. Returns a bytes object containing a valid NOTIFY request. The NOTIFY request is different between IETF SSDP and UPnP SSDP. In IETF, the 'location' and 'al' fields serve the same purpose, and can be provided together (if so, they should point to the same location) or not at all. In UPnP, the 'location' field MUST be provided, and 'al' is ignored. Sending both 'location' and 'al' is the more widely supported option. It does not, however, mean that all SSDP implementations would accept a packet with both. Therefore the option to send just one of these fields (or none at all) is supported. If in doubt, send both. If your notifications go ignored, opt to not send 'al'. :param host: The address (IP + port) that the NOTIFY will be sent about. This is usually a multicast address. :type host: str :param nt: Notification type. Indicates which device is sending the notification. :type nt: str :param usn: Unique identifier for the service. Usually this will be composed of a UUID or any other universal identifier. :type usn: str :param location: A URL for more information about the service. This parameter is only valid when sending a UPnP SSDP packet, not IETF. :type location: str :param al: Similar to 'location', but only supported on IETF SSDP, not UPnP. :type al: str :param max_age: Amount of time in seconds that the NOTIFY packet should be cached by clients receiving it. In UPnP, this header is required. :type max_age: int :param extra_fields: Extra header fields to send. UPnP SSDP section 1.1.3 allows for extra vendor-specific fields to be sent in the NOTIFY packet. According to the spec, the field names MUST be in the format of `token`.`domain-name`, for example `myheader.philips.com`. SSDPy, however, does not check this. Normally, headers should be in ASCII - but this function does not enforce that. :return: A bytes object containing the generated NOTIFY payload. """ if max_age is not None and not isinstance(max_age, int): raise ValueError("max_age must by of type: int") data = ( "NOTIFY * HTTP/1.1\r\n" "HOST:{}\r\n" "NT:{}\r\n" "NTS:ssdp:alive\r\n" "USN:{}\r\n" ).format(host, nt, usn) if location is not None: data += "LOCATION:{}\r\n".format(location) if al is not None: data += "AL:{}\r\n".format(al) if max_age is not None: data += "Cache-Control:max-age={}\r\n".format(max_age) if extra_fields is not None: for field, value in extra_fields.items(): data += "{}:{}\r\n".format(field, value) data += "\r\n" return data.encode("utf-8") ssdpy-0.4.1/ssdpy/server.py000066400000000000000000000162221376342612200157110ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import logging import socket import struct from .constants import ipv6_multicast_ip, ipv4_multicast_ip from .protocol import create_notify_payload from .http_helper import parse_headers from .compat import if_nametoindex, SO_BINDTODEVICE logger = logging.getLogger("ssdpy.server") class SSDPServer(object): """ A server that can listen to SSDP M-SEARCH requests and responds with appropriate NOTIFY packets when the ST matches its device_type. Example usage:: >>> server = SSDPServer("my-service", device_type="my-device-type") >>> server.serve_forever() This will listen to SSDP M-Searches (discovery) and respond with :param usn: A unique service name, which identifies your service. :type usn: str :param proto: Protocol to use, either ``ipv4`` or ``ipv6``. Defaults to ``ipv4.`` :type proto: str, optional :param device_type: The device type to respond as. Defaults to ``ssdp:rootdevice`` which is the base type for ssdp devices. :type device_type: str, optional :param port: Port to listen on. SSDP works on port 1900, which is the default value here. :type port: int, optional :param iface: Interface to bind to. When not provided, the operating system decides which interface should handle multicasts and binds to it. :type iface: bytes, optional :param address: A specific address to bind to. This is required when using IPv6, since you will have a link-local IP address in addition to at least one actual IP address. :type address: str, optional :param max_age: The maximum time, in seconds, for clients to cache notifications. :type max_age: int :param location: Canonical URL of the service. :type location: str :param al: Canonical URL of the service, but only supported in the IETF version of SSDP. Should be the same as ``location``. :type al: str :param extra_fields: Extra header fields to send. UPnP SSDP section 1.1.3 allows for extra vendor-specific fields to be sent in the NOTIFY packet. According to the spec, the field names MUST be in the format of `token`.`domain-name`, for example `myheader.philips.com`. SSDPy, however, does not check this and allows any field name - as long as it's ASCII. :type extra_fields: dict """ def __init__( self, usn, proto="ipv4", device_type="ssdp:rootdevice", port=1900, iface=None, address=None, max_age=None, location=None, al=None, extra_fields=None, ): allowed_protos = ("ipv4", "ipv6") if proto not in allowed_protos: raise ValueError("Invalid proto - expected one of {}".format(allowed_protos)) self.stopped = False self.usn = usn self.device_type = device_type self.al = al self.location = location self.max_age = max_age self._iface = iface self._extra_fields = {} if extra_fields is not None: for field, value in extra_fields.items(): try: field.encode("ascii") value.encode("ascii") self._extra_fields[field] = value except (UnicodeDecodeError, UnicodeEncodeError): raise ValueError("Invalid value for extra_field: %s=%s is not ASCII", field, value) if proto == "ipv4": self._af_type = socket.AF_INET self._broadcast_ip = ipv4_multicast_ip self._address = (self._broadcast_ip, port) bind_address = "0.0.0.0" elif proto == "ipv6": self._af_type = socket.AF_INET6 self._broadcast_ip = ipv6_multicast_ip self._address = (self._broadcast_ip, port, 0, 0) bind_address = "::" self.sock = socket.socket(self._af_type, socket.SOCK_DGRAM, socket.IPPROTO_UDP) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Bind to specific interface if iface is not None: self.sock.setsockopt(socket.SOL_SOCKET, SO_BINDTODEVICE, iface) # Subscribe to multicast address if proto == "ipv4": mreq = socket.inet_aton(self._broadcast_ip) if address is not None: mreq += socket.inet_aton(address) else: mreq += struct.pack(b"@I", socket.INADDR_ANY) self.sock.setsockopt( socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq, ) # Allow multicasts on loopback devices (necessary for testing) self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) elif proto == "ipv6": # In IPv6 we use the interface index, not the address when subscribing to the group mreq = socket.inet_pton(socket.AF_INET6, self._broadcast_ip) if iface is not None: iface_index = if_nametoindex(iface) # Send outgoing packets from the same interface self.sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_index) mreq += struct.pack(b"@I", iface_index) else: mreq += socket.inet_pton(socket.AF_INET6, "::") self.sock.setsockopt( socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq, ) self.sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, 1) self.sock.bind((bind_address, port)) def on_recv(self, data, address): logger.debug("Received packet from {}: {}".format(address, data)) try: headers = parse_headers(data) except ValueError: # Not an SSDP M-SEARCH; ignore. logger.debug("NOT M-SEARCH - SKIPPING") pass if data.startswith(b"M-SEARCH") and (headers.get("st") == self.device_type or headers.get("st") == "ssdp:all"): logger.info("Received qualifying M-SEARCH from {}".format(address)) logger.debug("M-SEARCH data: {}".format(headers)) notify = create_notify_payload( host=self._broadcast_ip, nt=self.device_type, usn=self.usn, location=self.location, al=self.al, max_age=self.max_age, extra_fields=self._extra_fields, ) logger.debug("Created NOTIFY: {}".format(notify)) try: self.sock.sendto(notify, address) except OSError as e: # Most commonly: We received a multicast from an IP not in our subnet logger.debug("Unable to send NOTIFY to {}: {}".format(address, e)) def serve_forever(self): """ Start listening for M-SEARCH discovery attempts and answer any that refers to our ``device_type`` or to ``ssdp:all``. This will block execution until an exception occurs. """ logger.info("Listening forever") try: while not self.stopped: data, address = self.sock.recvfrom(1024) self.on_recv(data, address) except Exception: self.sock.close() raise ssdpy-0.4.1/ssdpy/version.py000066400000000000000000000001761376342612200160710ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals VERSION = "0.4.1" ssdpy-0.4.1/tests/000077500000000000000000000000001376342612200140265ustar00rootroot00000000000000ssdpy-0.4.1/tests/__init__.py000066400000000000000000000001531376342612200161360ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals ssdpy-0.4.1/tests/compat.py000066400000000000000000000002271376342612200156640ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import sys PY2 = sys.version_info[0] == 2 ssdpy-0.4.1/tests/test_cli_client.py000066400000000000000000000045011376342612200175440ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import pytest import json from ssdpy.cli import client as client_cli def test_client_cli_invalid_arguments(): with pytest.raises(SystemExit): client_cli.parse_args(("--invalid-argument", )) def test_version(): with pytest.raises(SystemExit): client_cli.parse_args(("--version", )) def test_verbose(): args = client_cli.parse_args(("ssdp:test", "-v")) assert args.verbose is True args = client_cli.parse_args(("ssdp:test", "--verbose")) assert args.verbose is True def test_no_st(): with pytest.raises(SystemExit): client_cli.parse_args(("")) def test_basic_discovery(mocker): mocker.patch.object(client_cli, "SSDPClient") client_cli.main(("ssdp:test", )) client_cli.SSDPClient.assert_called_once_with( proto="ipv4", port=1900, ttl=2, iface=None, timeout=5, address=None, ) # TODO: Check that client.m_search has been called. def test_client_discovery_ipv6(mocker): mocker.patch.object(client_cli, "SSDPClient") client_cli.main(("ssdp:test", "--ipv6")) client_cli.SSDPClient.assert_called_once_with( proto="ipv6", port=1900, ttl=2, iface=None, timeout=5, address=None, ) mocker.patch.object(client_cli, "SSDPClient") client_cli.main(("ssdp:test", "-6")) client_cli.SSDPClient.assert_called_once_with( proto="ipv6", port=1900, ttl=2, iface=None, timeout=5, address=None, ) def test_client_discovery_all_args(mocker): mocker.patch.object(client_cli, "SSDPClient") client_cli.main( ( "ssdp:test", "-v", "-6", "-t", "100", "-o", "200", "-i", "test_iface", "-p", "0", "-m", "5", ) ) client_cli.SSDPClient.assert_called_once_with( proto="ipv6", port=0, ttl=100, iface=b"test_iface", timeout=200, address=None, ) def test_client_json_output(capsys): client_cli.main( ("ssdp:all", "-j") ) output = capsys.readouterr() json.loads(output.out) ssdpy-0.4.1/tests/test_cli_server.py000066400000000000000000000051671376342612200176050ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import pytest from ssdpy.cli import server as server_cli def test_invalid_arguments(): with pytest.raises(SystemExit): server_cli.parse_args(("TestServer", "--invalid-argument")) def test_version(): with pytest.raises(SystemExit): server_cli.parse_args(("TestServer", "--version")) def test_verbose(): args = server_cli.parse_args(("TestServer", "-v")) assert args.verbose is True args = server_cli.parse_args(("TestServer", "--verbose")) assert args.verbose is True def test_ssdpserver_init(mocker): mocker.patch.object(server_cli, "SSDPServer") server_cli.main(("TestServer",)) server_cli.SSDPServer.assert_called_once_with( "TestServer", address=None, al=None, device_type="ssdp:rootdevice", iface=None, location=None, max_age=None, port=1900, proto="ipv4", extra_fields=None, ) def test_ssdpserver_init_with_ipv6(mocker): mocker.patch.object(server_cli, "SSDPServer") server_cli.main(("TestServer", "-6")) server_cli.SSDPServer.assert_called_once_with( "TestServer", address=None, al=None, device_type="ssdp:rootdevice", iface=None, location=None, max_age=None, port=1900, proto="ipv6", extra_fields=None, ) mocker.patch.object(server_cli, "SSDPServer") server_cli.main(("TestServer", "--ipv6")) server_cli.SSDPServer.assert_called_once_with( "TestServer", address=None, al=None, device_type="ssdp:rootdevice", iface=None, location=None, max_age=None, port=1900, proto="ipv6", extra_fields=None, ) def test_ssdpserver_init_with_args(mocker): mocker.patch.object(server_cli, "SSDPServer") server_cli.main( ( "TestServer", "-i", "lo", "-a", "test-address", "-l", "test-location", "-p", "0", "-6", "-t", "test-device", "--max-age", "0", "-e", "test-field", "foo" ) ) server_cli.SSDPServer.assert_called_once_with( "TestServer", address="test-address", al="test-location", device_type="test-device", iface=b"lo", location="test-location", max_age=0, port=0, proto="ipv6", extra_fields={"test-field": "foo"}, ) ssdpy-0.4.1/tests/test_client.py000066400000000000000000000021701376342612200167150ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import os import errno import pytest from ssdpy import SSDPClient from ssdpy.client import discover def test_client_accepts_ipv4(): SSDPClient(proto="ipv4") def test_client_accepts_ipv6(): SSDPClient(proto="ipv6") def test_client_rejects_bad_proto(): with pytest.raises(ValueError): SSDPClient(proto="invalid") @pytest.mark.skipif( os.environ.get("CI") == "true", reason="Not all development environments have a predictable loopback device name", ) def test_client_binds_iface(): SSDPClient(iface=b"lo") @pytest.mark.skipif( os.environ.get("CI") == "true", reason="IPv6 testing is broken in GitHub Actions, see https://github.com/actions/virtual-environments/issues/668", ) def test_client_bind_iface_ipv6(): try: SSDPClient(proto="ipv6", iface=b"lo") except OSError as e: if e.errno != errno.ENOPROTOOPT: # Protocol not supported raise def test_client_bind_address_ipv4(): SSDPClient(address="127.0.0.1") def test_discover(): discover() ssdpy-0.4.1/tests/test_compat.py000066400000000000000000000011341376342612200167210ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import pytest from .compat import PY2 from ssdpy.compat import if_nametoindex def test_if_nametoindex_none(): if PY2: with pytest.raises(TypeError): if_nametoindex(None) def test_if_nametoindex_int(): if PY2: with pytest.raises(TypeError): if_nametoindex(0) def test_if_nametoindex_nodevice(): with pytest.raises(OSError): if_nametoindex("does-not-exist") def test_if_nametoindex(): assert type(if_nametoindex(b"lo")) is int ssdpy-0.4.1/tests/test_http_helper.py000066400000000000000000000016121376342612200177550ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import pytest from ssdpy.http_helper import parse_headers def test_parse_headers(): good_response = b"HTTP/1.1 200 OK\r\n" b"MX: 5\r\n" headers = parse_headers(good_response) assert headers.get("mx") == "5" def test_parse_headers_invalid_header(): good_response = b"HTTP/1.1 200 OK\r\n" b"MX: 5\r\n" headers = parse_headers(good_response) assert headers.get("should-not-exist") is None def test_parse_headers_invalid_response(): bad_response = b"not an http response" with pytest.raises(ValueError): parse_headers(bad_response) def test_parse_headers_bad_response_header(): bad_response = ( b"HTTP/1.1 200 OK\r\n" b"Header: OK\r\n" b"Another-header-not-ok\r\n" ) with pytest.raises(ValueError): parse_headers(bad_response) ssdpy-0.4.1/tests/test_protocol.py000066400000000000000000000056361376342612200173120ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import pytest from ssdpy.protocol import create_msearch_payload, create_notify_payload from ssdpy.http_helper import parse_headers def test_msearch_payload(): data = create_msearch_payload("239.255.255.250:1900", "ssdp:all", mx=1) data_headers = parse_headers(data) assert data_headers.get("host") == "239.255.255.250:1900" assert data_headers.get("st") == "ssdp:all" assert data_headers.get("man") == '"ssdp:discover"' assert data_headers.get("mx") == "1" def test_notify_payload(): data = create_notify_payload("239.255.255.250:1900", "testdevice", "ssdpy-test") data_headers = parse_headers(data) assert data_headers.get("host") == "239.255.255.250:1900" assert data_headers.get("nt") == "testdevice" assert data_headers.get("usn") == "ssdpy-test" assert data_headers.get("non-existant-header") is None def test_notify_location(): data = create_notify_payload( "239.255.255.250:1900", "testdevice", "ssdpy-test", location="http://localhost", ) data_headers = parse_headers(data) assert data_headers.get("host") == "239.255.255.250:1900" assert data_headers.get("nt") == "testdevice" assert data_headers.get("usn") == "ssdpy-test" assert data_headers.get("non-existant-header") is None assert data_headers.get("location") == "http://localhost" def test_notify_al(): data = create_notify_payload("239.255.255.250:1900", "testdevice", "ssdpy-test", al="http://localhost") data_headers = parse_headers(data) assert data_headers.get("host") == "239.255.255.250:1900" assert data_headers.get("nt") == "testdevice" assert data_headers.get("usn") == "ssdpy-test" assert data_headers.get("non-existant-header") is None assert data_headers.get("al") == "http://localhost" def test_notify_age(): data = create_notify_payload("239.255.255.250:1900", "testdevice", "ssdpy-test", max_age=999) data_headers = parse_headers(data) assert data_headers.get("host") == "239.255.255.250:1900" assert data_headers.get("nt") == "testdevice" assert data_headers.get("usn") == "ssdpy-test" assert data_headers.get("non-existant-header") is None assert data_headers.get("cache-control") == "max-age=999" def test_notify_edge_cases(): with pytest.raises(ValueError): create_notify_payload("x", "y", "z", max_age="not-a-number") def test_notify_extra_fields(): data = create_notify_payload( "239.255.255.250:1900", "testdevice", "ssdpy-test", extra_fields={"test-header": "test-value", "test-header.domain.com": "test-value2"}, ) data_headers = parse_headers(data) assert data_headers.get("test-header") == "test-value" assert data_headers.get("test-header.domain.com") == "test-value2" assert data_headers.get("non-existant-header") is None ssdpy-0.4.1/tests/test_server.py000066400000000000000000000044431376342612200167520ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals import threading import time import pytest import errno import os from ssdpy import SSDPServer def test_server_ipv4(): server = SSDPServer("test-server", proto="ipv4") server.sock.settimeout(5) server_thread = threading.Thread(target=server.serve_forever) server_thread.start() time.sleep(0.5) server.stopped = True server_thread.join() @pytest.mark.skipif( os.environ.get("CI") == "true", reason="IPv6 testing is broken in GitHub Actions, see https://github.com/actions/virtual-environments/issues/668", ) def test_server_ipv6(): server = SSDPServer("test-server-ipv6", proto="ipv6") server.sock.settimeout(5) server_thread = threading.Thread(target=server.serve_forever) server_thread.start() time.sleep(0.5) server.stopped = True server_thread.join() def test_server_invalid_proto(): with pytest.raises(ValueError): SSDPServer("test-server", proto="invalid") @pytest.mark.skipif( os.environ.get("CI") == "true", reason="Not all development environments have a predictable loopback device name", ) def test_server_binds_iface(): SSDPServer("test-server", iface=b"lo") def test_server_bind_address_ipv4(): SSDPServer("test-server", address="127.0.0.1") @pytest.mark.skipif( os.environ.get("CI") == "true", reason="IPv6 testing is broken in GitHub Actions, see https://github.com/actions/virtual-environments/issues/668", ) def test_server_bind_address_ipv6(): SSDPServer("test-server", address="::1", proto="ipv6") @pytest.mark.skipif( os.environ.get("CI") == "true", reason="IPv6 testing is broken in GitHub Actions, see https://github.com/actions/virtual-environments/issues/668", ) def test_server_bind_address_and_iface_ipv6(): try: SSDPServer("test-server", address="::1", proto="ipv6", iface=b"lo") except OSError as e: if e.errno != errno.ENOPROTOOPT: # Protocol not supported raise def test_server_extra_fields(): SSDPServer("test-server", extra_fields={"test-field": "foo", "test-field2": "bar"}) def test_server_extra_fields_non_ascii(): with pytest.raises(ValueError): SSDPServer("test-server", extra_fields={"invalid-field™": "foo"})