├── .bandit ├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── linter-requirements.txt │ └── release.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── setup.cfg ├── ssdp ├── __init__.py ├── __main__.py ├── aio.py ├── deprecation.py ├── lexers.py ├── messages.py └── network.py └── tests ├── __init__.py ├── conftest.py ├── fixtures.py ├── test_deprecation.py ├── test_lexers.py ├── test_main.py ├── test_messages.py └── test_network.py /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: tests 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | max_line_length = 88 13 | 14 | [*.{json,yml,yaml,js,jsx,vue,toml}] 15 | indent_size = 2 16 | 17 | [*.{html,htm,svg,xml}] 18 | indent_size = 2 19 | max_line_length = 120 20 | 21 | [LICENSE] 22 | insert_final_newline = false 23 | 24 | [*.{md,markdown}] 25 | indent_size = 2 26 | max_line_length = 80 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: codingjoe 2 | custom: https://www.paypal.me/codingjoe 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | 10 | lint: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | lint-command: 15 | - "bandit -r ssdp -x tests" 16 | - "black --check --diff ." 17 | - "flake8 ." 18 | - "isort --check-only --diff ." 19 | - "pydocstyle ." 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.x" 26 | cache: "pip" 27 | cache-dependency-path: ".github/workflows/linter-requirements.txt" 28 | - run: python -m pip install -r .github/workflows/linter-requirements.txt 29 | - run: ${{ matrix.lint-command }} 30 | 31 | dist: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - run: sudo apt install -y gettext 36 | - uses: actions/setup-python@v5 37 | with: 38 | python-version: "3.x" 39 | - run: python -m pip install --upgrade build wheel twine readme-renderer 40 | - run: python -m build --sdist --wheel 41 | - run: python -m twine check dist/* 42 | - uses: actions/upload-artifact@v4 43 | with: 44 | path: dist/* 45 | 46 | PyTest: 47 | needs: 48 | - lint 49 | strategy: 50 | matrix: 51 | python-version: 52 | - "3.8" 53 | - "3.9" 54 | - "3.10" 55 | - "3.11" 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: actions/setup-python@v5 60 | with: 61 | python-version: ${{ matrix.python-version }} 62 | - run: python -m pip install --upgrade setuptools wheel codecov 63 | - run: python -m pip install -e ".[test]" 64 | - run: python -m pytest -m "not cli" 65 | - uses: codecov/codecov-action@v5 66 | 67 | CLI: 68 | needs: 69 | - lint 70 | strategy: 71 | matrix: 72 | os: 73 | - ubuntu-latest 74 | - windows-latest 75 | - macos-latest 76 | runs-on: ${{ matrix.os }} 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: actions/setup-python@v5 80 | with: 81 | python-version: "3.x" 82 | - run: python -m pip install --upgrade setuptools wheel codecov 83 | - run: python -m pip install -e ".[cli,test]" 84 | - run: python -m pytest -m "cli" 85 | - uses: codecov/codecov-action@v5 86 | -------------------------------------------------------------------------------- /.github/workflows/linter-requirements.txt: -------------------------------------------------------------------------------- 1 | bandit==1.7.5 2 | black==24.3.0 3 | flake8==6.0.0 4 | isort==5.12.0 5 | pydocstyle[toml]==6.3.0 6 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | 9 | PyPi: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - run: python -m pip install --upgrade pip build wheel twine 17 | - run: python -m build --sdist --wheel 18 | - run: python -m twine upload dist/* 19 | env: 20 | TWINE_USERNAME: __token__ 21 | TWINE_PASSWORD: ${{ secrets.TWINE_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # SCM 104 | _version.py 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Johannes Hoppe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude .* 2 | prune tests 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python SSDP 2 | 3 | Python asyncio library for Simple Service Discovery Protocol (SSDP). 4 | 5 | SSDP is a UPnP substandard. For more information see: 6 | https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol 7 | 8 | ## Setup 9 | 10 | ```bash 11 | python3 -m pip install ssdp # lightweight, without any dependencies 12 | # or 13 | python3 -m pip install ssdp[cli] # with cli support for testing and debugging 14 | ``` 15 | 16 | ## Usage 17 | 18 | ### CLI 19 | 20 | ```console-interactive 21 | $ ssdp --help 22 | Usage: ssdp [OPTIONS] COMMAND [ARGS]... 23 | 24 | SSDP command line interface. 25 | 26 | Options: 27 | -v, --verbose Increase verbosity. 28 | --help Show this message and exit. 29 | 30 | Commands: 31 | discover Send out an M-SEARCH request and listening for responses. 32 | ``` 33 | 34 | #### Discover 35 | 36 | Discover devices on the network and print the responses. 37 | 38 | ```console 39 | ssdp discover --help 40 | Usage: ssdp discover [OPTIONS] 41 | 42 | Send out an M-SEARCH request and listening for responses. 43 | 44 | Options: 45 | -b, --bind TEXT Specify alternate bind address [default: all 46 | interfaces] 47 | --search-target, --st TEXT Search target [default: ssdp:all] 48 | --max-wait, --mx INTEGER Maximum wait time in seconds [default: 5] 49 | --help Show this message and exit. 50 | ``` 51 | 52 | Example: 53 | 54 | ```console 55 | $ ssdp discover 56 | [::]:1900 - - [Sun Jun 11 12:07:09 2023] M-SEARCH * HTTP/1.1 57 | HOST: 239.255.255.250:1900 58 | MAN: "ssdp:discover" 59 | MX: 5 60 | ST: ssdp:all 61 | 62 | [::ffff:192.168.178.1]:1900 - - [Sun Jun 11 12:07:09 2023] HTTP/1.1 200 OK 63 | Cache-Control: max-age=1800 64 | Location: http://192.168.178.1:49000/MediaServerDevDesc.xml 65 | Server: FRITZ!Box 7590 UPnP/1.0 AVM FRITZ!Box 7590 154.07.50 66 | Ext: 67 | ST: upnp:rootdevice 68 | USN: uuid:fa095ecc-e13e-40e7-8e6c-3ca62f98471f::upnp:rootdevice 69 | ``` 70 | 71 | ### Python API 72 | 73 | #### Messages 74 | 75 | The SSDP library provides two classes for SSDP messages: `SSDPRequest` and 76 | `SSDPResponse`. Both classes are subclasses of `SSDPMessage` and provide 77 | the following methods: 78 | 79 | - `parse`: Parse a SSDP message from a string. 80 | - `__bytes__`: Convert the SSDP message to a bytes object. 81 | - `__str__`: Convert the SSDP message to a string. 82 | 83 | You can parse a SSDP message from a string with the `parse` method. 84 | It will return a `SSDPRequest` or `SSDPResponse` object depending 85 | on the message type. 86 | 87 | ```pycon 88 | >>> import ssdp.messages 89 | >>> ssdp.messages.SSDPRequest.parse('NOTIFY * HTTP/1.1\r\n\r\n') 90 | 91 | >>> ssdp.messages.SSDPResponse.parse('HTTP/1.1 200 OK\r\n\r\n') 92 | 93 | ``` 94 | 95 | ##### SSDPRequest 96 | 97 | ```pycon 98 | >>> from ssdp.messages import SSDPRequest 99 | >>> SSDPRequest('NOTIFY', headers={ 100 | ... 'HOST': '10.0.0.42', 101 | ... 'NT': 'upnp:rootdevice', 102 | ... 'NTS': 'ssdp:alive', 103 | ... }) 104 | 105 | ``` 106 | 107 | The `SSDPRequest` class provides the a `sendto` method to send the request 108 | over a open transport. 109 | 110 | ```pycon 111 | >>> from ssdp import network, messages 112 | >>> notify = messages.SSDPRequest('NOTIFY') 113 | >>> notify.sendto(transport, (network.MULTICAST_ADDRESS_IPV4, network.PORT)) 114 | ``` 115 | 116 | ##### SSDPResponse 117 | 118 | ```pycon 119 | >>> from ssdp.messages import SSDPResponse 120 | >>> SSDPResponse(200, 'OK', headers={ 121 | ... 'CACHE-CONTROL': 'max-age=1800', 122 | ... 'LOCATION': 'http://10.0.0.1:80/description.xml', 123 | ... 'SERVER': 'Linux/2.6.18 UPnP/1.0 quick_ssdp/1.0', 124 | ... 'ST': 'upnp:rootdevice', 125 | ... }) 126 | 127 | ``` 128 | 129 | #### Asyncio SSD Protocol datagram endpoint 130 | 131 | The `aio.SimpleServiceDiscoveryProtocol` class is a subclass of 132 | `asyncio.DatagramProtocol` and provides the following additional methods: 133 | 134 | - `response_received`: Called when a SSDP response was received. 135 | - `request_received`: Called when a SSDP request was received. 136 | 137 | The protocol can be used to react to SSDP messages in an asyncio event loop. 138 | 139 | This example sends a SSDP NOTIFY message and prints all received SSDP messages: 140 | 141 | ```python 142 | #!/usr/bin/env python3 143 | import asyncio 144 | import socket 145 | 146 | from ssdp import aio, messages, network 147 | 148 | 149 | class MyProtocol(aio.SimpleServiceDiscoveryProtocol): 150 | 151 | def response_received(self, response, addr): 152 | print(response, addr) 153 | 154 | def request_received(self, request, addr): 155 | print(request, addr) 156 | 157 | 158 | loop = asyncio.get_event_loop() 159 | connect = loop.create_datagram_endpoint(MyProtocol, family=socket.AF_INET) 160 | transport, protocol = loop.run_until_complete(connect) 161 | 162 | notify = messages.SSDPRequest('NOTIFY') 163 | notify.sendto(transport, (network.MULTICAST_ADDRESS_IPV4, network.PORT)) 164 | 165 | try: 166 | loop.run_forever() 167 | except KeyboardInterrupt: 168 | pass 169 | 170 | transport.close() 171 | loop.close() 172 | ``` 173 | 174 | ## SSDP lexer plugin for [Pygments][pygments] 175 | 176 | The SSDP library comes with a lexer plugin for [Pygments][pygments] 177 | to highlight SSDP messages. It's based on a HTTP lexer and adds SSDP 178 | specific keywords. 179 | 180 | You can install the plugin with the following command: 181 | 182 | ```bash 183 | pip install ssdp[pymgments] # included in ssdp[cli] 184 | ``` 185 | 186 | You can either get the lexer by name: 187 | 188 | ```pycon 189 | >>> from pygments.lexers import get_lexer_by_name 190 | >>> get_lexer_by_name('ssdp') 191 | 192 | ``` 193 | 194 | Highlighting a SSDP message, could look like this: 195 | 196 | ```python 197 | #/usr/bin/env python3 198 | from pygments import highlight 199 | from pygments.lexers import get_lexer_by_name 200 | from pygments.formatters import TerminalFormatter 201 | 202 | 203 | if __name__ == '__main__': 204 | lexer = get_lexer_by_name('ssdp') 205 | formatter = TerminalFormatter() 206 | code = 'NOTIFY * HTTP/1.1\r\nHOST: localhost:1900' 207 | msg = highlight(code, lexer, formatter) 208 | print(msg) 209 | ``` 210 | 211 | [pygments]: https://pygments.org/ 212 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.2", "flit_scm", "wheel"] 3 | build-backend = "flit_scm:buildapi" 4 | 5 | [project] 6 | name = "ssdp" 7 | authors = [ 8 | { name = "Johannes Maron", email = "johannes@maron.family" }, 9 | ] 10 | readme = "README.md" 11 | license = { file = "LICENSE" } 12 | keywords = ["ssdp", "python", "asyncio", "upnp", "iot"] 13 | dynamic = ["version", "description"] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Console", 17 | "Intended Audience :: Developers", 18 | "Intended Audience :: Information Technology", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Framework :: AsyncIO", 29 | "Topic :: System :: Networking", 30 | "Topic :: Software Development :: Libraries", 31 | "Topic :: Home Automation", 32 | ] 33 | requires-python = ">=3.8" 34 | 35 | [project.optional-dependencies] 36 | test = [ 37 | "pytest", 38 | "pytest-cov", 39 | ] 40 | cli = [ 41 | "click", 42 | "Pygments", 43 | ] 44 | pygments = ["Pygments"] 45 | 46 | [project.scripts] 47 | ssdp = "ssdp:__main__.ssdp" 48 | 49 | [project.entry-points."pygments.lexers"] 50 | ssdp = "ssdp.lexers:SSDPLexer" 51 | 52 | [project.urls] 53 | Project-URL = "https://github.com/codingjoe/ssdp" 54 | Changelog = "https://github.com/codingjoe/ssdp/releases" 55 | 56 | [tool.flit.module] 57 | name = "ssdp" 58 | 59 | [tool.setuptools_scm] 60 | write_to = "ssdp/_version.py" 61 | 62 | [tool.pytest.ini_options] 63 | minversion = "6.0" 64 | addopts = "--cov --tb=short -rxs" 65 | testpaths = ["tests"] 66 | 67 | [tool.coverage.run] 68 | source = ["ssdp"] 69 | 70 | [tool.coverage.report] 71 | show_missing = true 72 | 73 | [tool.isort] 74 | atomic = true 75 | line_length = 88 76 | known_first_party = "ssdp, tests" 77 | include_trailing_comma = true 78 | default_section = "THIRDPARTY" 79 | combine_as_imports = true 80 | 81 | [tool.pydocstyle] 82 | add_ignore = "D1" 83 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=88 3 | select = C,E,F,W,B,B950 4 | ignore = E203, E501, W503 5 | exclude = venv,.tox,.eggs 6 | -------------------------------------------------------------------------------- /ssdp/__init__.py: -------------------------------------------------------------------------------- 1 | """Python asyncio library for Simple Service Discovery Protocol (SSDP).""" 2 | 3 | from . import _version 4 | from .aio import SimpleServiceDiscoveryProtocol 5 | from .deprecation import moved 6 | from .messages import SSDPMessage, SSDPRequest, SSDPResponse 7 | 8 | __version__ = _version.version 9 | __all__ = [] 10 | 11 | VERSION = _version.version_tuple 12 | 13 | SimpleServiceDiscoveryProtocol = moved(SimpleServiceDiscoveryProtocol) 14 | SSDPMessage = moved(SSDPMessage) 15 | SSDPRequest = moved(SSDPRequest) 16 | SSDPResponse = moved(SSDPResponse) 17 | -------------------------------------------------------------------------------- /ssdp/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import asyncio 3 | import logging 4 | import time 5 | 6 | from ssdp import messages, network 7 | from ssdp.aio import SSDP 8 | 9 | try: 10 | import click 11 | from pygments import formatters, highlight 12 | 13 | from .lexers import SSDPLexer 14 | except ImportError as e: 15 | raise ImportError( 16 | "The SSDP CLI requires needs to be installed via `pip install ssdp[cli]`." 17 | ) from e 18 | 19 | 20 | class ConsoleMessageProcessor: 21 | """Print SSDP messages to stdout.""" 22 | 23 | def request_received(self, request: messages.SSDPRequest, addr: tuple): 24 | self.pprint(request, addr) 25 | 26 | def response_received(self, response: messages.SSDPResponse, addr: tuple): 27 | self.pprint(response, addr) 28 | 29 | @staticmethod 30 | def pprint(msg, addr): 31 | """Pretty print the message.""" 32 | host = f"[{addr[0]}]" if ":" in addr[0] else addr[0] 33 | host = click.style(host, fg="green", bold=True) 34 | port = click.style(str(addr[1]), fg="yellow", bold=True) 35 | pretty_msg = highlight(str(msg), SSDPLexer(), formatters.TerminalFormatter()) 36 | click.echo("%s:%s - - [%s] %s" % (host, port, time.asctime(), pretty_msg)) 37 | 38 | 39 | class PrintSSDMessageProtocol(ConsoleMessageProcessor, SSDP): 40 | pass 41 | 42 | 43 | @click.group() 44 | @click.option("-v", "--verbose", count=True, help="Increase verbosity.") 45 | def ssdp(verbose): 46 | """SSDP command line interface.""" 47 | logging.basicConfig( 48 | level=max(10, 10 * (2 - verbose)), 49 | format="%(levelname)s: [%(asctime)s] %(message)s", 50 | handlers=[logging.StreamHandler()], 51 | ) 52 | 53 | 54 | @ssdp.command() 55 | @click.option( 56 | "--bind", 57 | "-b", 58 | help="Specify alternate bind address [default: all interfaces]", 59 | ) 60 | @click.option( 61 | "--search-target", 62 | "--st", 63 | default="ssdp:all", 64 | help="Search target [default: ssdp:all]", 65 | ) 66 | @click.option( 67 | "--max-wait", 68 | "--mx", 69 | default=5, 70 | help="Maximum wait time in seconds [default: 5]", 71 | ) 72 | def discover(bind, search_target, max_wait): 73 | """Send out an M-SEARCH request and listening for responses.""" 74 | family, addr = network.get_best_family(bind, network.PORT) 75 | loop = asyncio.get_event_loop() 76 | 77 | connect = loop.create_datagram_endpoint(PrintSSDMessageProtocol, family=family) 78 | transport, protocol = loop.run_until_complete(connect) 79 | 80 | target = network.MULTICAST_ADDRESS_IPV4, network.PORT 81 | 82 | search_request = messages.SSDPRequest( 83 | "M-SEARCH", 84 | headers={ 85 | "HOST": "%s:%d" % target, 86 | "MAN": '"ssdp:discover"', 87 | "MX": str(max_wait), # seconds to delay response [1..5] 88 | "ST": search_target, 89 | }, 90 | ) 91 | 92 | target = network.MULTICAST_ADDRESS_IPV4, network.PORT 93 | 94 | search_request.sendto(transport, target) 95 | 96 | PrintSSDMessageProtocol.pprint(search_request, addr[:2]) 97 | try: 98 | loop.run_until_complete(asyncio.sleep(4)) 99 | finally: 100 | transport.close() 101 | 102 | 103 | if __name__ == "__main__": # pragma: no cover 104 | ssdp() 105 | -------------------------------------------------------------------------------- /ssdp/aio.py: -------------------------------------------------------------------------------- 1 | """ 2 | SSDP implementation for Python's asyncio event loops. 3 | 4 | This module implements SSDP protocol for asyncio event loops. It is based on 5 | :class:`asyncio.DatagramProtocol` and provides a simple interface for 6 | implementing SSDP clients and servers. 7 | """ 8 | import asyncio 9 | import errno 10 | import logging 11 | 12 | from . import messages 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | __all__ = ["SSDP", "SimpleServiceDiscoveryProtocol"] 17 | 18 | 19 | class SimpleServiceDiscoveryProtocol(asyncio.DatagramProtocol): 20 | """ 21 | Simple Service Discovery Protocol (SSDP). 22 | 23 | SSDP is part of UPnP protocol stack. For more information see: 24 | https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol 25 | """ 26 | 27 | def datagram_received(self, data, addr): 28 | data = data.decode() 29 | logger.debug("%s:%s – – %s", *addr, data) 30 | 31 | if data.startswith("HTTP/"): 32 | self.response_received(messages.SSDPResponse.parse(data), addr) 33 | else: 34 | self.request_received(messages.SSDPRequest.parse(data), addr) 35 | 36 | def response_received(self, response, addr): 37 | """ 38 | Being called when some response is received. 39 | 40 | Args: 41 | response (ssdp.messages.SSDPResponse): Received response. 42 | addr (Tuple[str, int]): Tuple containing IP address and port number. 43 | 44 | """ 45 | raise NotImplementedError() 46 | 47 | def request_received(self, request, addr): 48 | """ 49 | Being called when some request is received. 50 | 51 | Args: 52 | request (ssdp.messages.SSDPRequest): Received request. 53 | addr (Tuple[str, int]): Tuple containing IP address and port number. 54 | 55 | """ 56 | raise NotImplementedError() 57 | 58 | def error_received(self, exc): 59 | if exc == errno.EAGAIN or exc == errno.EWOULDBLOCK: 60 | logger.exception("Blocking IO error", exc_info=exc) 61 | else: 62 | raise exc 63 | 64 | def connection_lost(self, exc): 65 | logger.exception("Connection lost", exc_info=exc) 66 | 67 | 68 | SSDP = SimpleServiceDiscoveryProtocol # alias 69 | -------------------------------------------------------------------------------- /ssdp/deprecation.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import warnings 3 | 4 | 5 | def moved(cls): 6 | @functools.wraps(cls) 7 | def wrapper(*args, **kwargs): 8 | warnings.warn( 9 | f"'{__name__}.{cls.__qualname__}' has moved." 10 | f" Please import from: {cls.__module__}.{cls.__qualname__}", 11 | category=DeprecationWarning, 12 | stacklevel=2, 13 | ) 14 | return cls(*args, **kwargs) 15 | 16 | return wrapper 17 | -------------------------------------------------------------------------------- /ssdp/lexers.py: -------------------------------------------------------------------------------- 1 | from pygments import lexer, lexers, token 2 | 3 | 4 | class SSDPLexer(lexers.HttpLexer): 5 | """Lexer for SSDP messages.""" 6 | 7 | name = "SSDP" 8 | aliases = ["ssdp"] 9 | 10 | tokens = { 11 | "root": [ 12 | ( 13 | r"(M-SEARCH|NOTIFY)( +)([^ ]+)( +)" 14 | r"(HTTP)(/)(1\.[01]|2(?:\.0)?|3)(\r?\n|\Z)", 15 | lexer.bygroups( 16 | token.Name.Function, 17 | token.Text, 18 | token.Name.Namespace, 19 | token.Text, 20 | token.Keyword.Reserved, 21 | token.Operator, 22 | token.Number, 23 | token.Text, 24 | ), 25 | "headers", 26 | ), 27 | ( 28 | r"(HTTP)(/)(1\.[01]|2(?:\.0)?|3)( +)(\d{3})(?:( +)([^\r\n]*))?(\r?\n|\Z)", 29 | lexer.bygroups( 30 | token.Keyword.Reserved, 31 | token.Operator, 32 | token.Number, 33 | token.Text, 34 | token.Number, 35 | token.Text, 36 | token.Name.Exception, 37 | token.Text, 38 | ), 39 | "headers", 40 | ), 41 | ], 42 | "headers": [ 43 | ( 44 | r"([^\s:]+)( *)(:)( *)([^\r\n]+)(\r?\n|\Z)", 45 | lexers.HttpLexer.header_callback, 46 | ), 47 | ( 48 | r"([\t ]+)([^\r\n]+)(\r?\n|\Z)", 49 | lexers.HttpLexer.continuous_header_callback, 50 | ), 51 | (r"\r?\n", token.Text, "content"), 52 | ], 53 | "content": [(r".+", lexers.HttpLexer.content_callback)], 54 | } 55 | -------------------------------------------------------------------------------- /ssdp/messages.py: -------------------------------------------------------------------------------- 1 | import email.parser 2 | import logging 3 | 4 | logger = logging.getLogger("ssdp") 5 | 6 | 7 | class SSDPMessage: 8 | """Simplified HTTP message to serve as a SSDP message.""" 9 | 10 | def __init__(self, version="HTTP/1.1", headers=None): 11 | if headers is None: 12 | headers = [] 13 | elif isinstance(headers, dict): 14 | headers = headers.items() 15 | 16 | self.version = version 17 | self.headers = list(headers) 18 | 19 | @classmethod 20 | def parse(cls, msg: str): 21 | """ 22 | Parse message a string into a :class:`SSDPMessage` instance. 23 | 24 | Args: 25 | msg (str): Message string. 26 | 27 | Returns: 28 | SSDPMessage: Message parsed from string. 29 | 30 | """ 31 | if msg.startswith("HTTP/"): 32 | return SSDPResponse.parse(msg) 33 | else: 34 | return SSDPRequest.parse(msg) 35 | 36 | @classmethod 37 | def parse_headers(cls, msg): 38 | """ 39 | Parse HTTP headers. 40 | 41 | Args: 42 | msg (str): HTTP message. 43 | 44 | Returns: 45 | (List[Tuple[str, str]]): List of header tuples. 46 | 47 | """ 48 | return list(email.parser.Parser().parsestr(msg).items()) 49 | 50 | def __str__(self): 51 | """Return full HTTP message.""" 52 | raise NotImplementedError() 53 | 54 | def __bytes__(self): 55 | """Return full HTTP message as bytes.""" 56 | return self.__str__().encode() + b"\r\n\r\n" 57 | 58 | 59 | class SSDPResponse(SSDPMessage): 60 | """Simple Service Discovery Protocol (SSDP) response.""" 61 | 62 | def __init__(self, status_code, reason, **kwargs): 63 | self.status_code = int(status_code) 64 | self.reason = reason 65 | super().__init__(**kwargs) 66 | 67 | @classmethod 68 | def parse(cls, msg: str): 69 | """Parse message string to response object.""" 70 | lines = msg.splitlines() 71 | version, status_code, reason = lines[0].split() 72 | headers = cls.parse_headers("\r\n".join(lines[1:])) 73 | return cls( 74 | version=version, status_code=status_code, reason=reason, headers=headers 75 | ) 76 | 77 | def sendto(self, transport, addr): 78 | """ 79 | Send response to a given address via given transport. 80 | 81 | Args: 82 | transport (asyncio.DatagramTransport): 83 | Write transport to send the message on. 84 | addr (Tuple[str, int]): 85 | IP address and port pair to send the message to. 86 | 87 | """ 88 | logger.debug("%s:%s - - %s", *(addr + (self,))) 89 | transport.sendto(bytes(self), addr) 90 | 91 | def __str__(self): 92 | """Return complete SSDP response.""" 93 | lines = list() 94 | lines.append(" ".join([self.version, str(self.status_code), self.reason])) 95 | for header in self.headers: 96 | lines.append("%s: %s" % header) 97 | return "\r\n".join(lines) 98 | 99 | 100 | class SSDPRequest(SSDPMessage): 101 | """Simple Service Discovery Protocol (SSDP) request.""" 102 | 103 | def __init__(self, method, uri="*", version="HTTP/1.1", headers=None): 104 | self.method = method 105 | self.uri = uri 106 | super().__init__(version=version, headers=headers) 107 | 108 | @classmethod 109 | def parse(cls, msg: str): 110 | """Parse message string to request object.""" 111 | lines = msg.splitlines() 112 | method, uri, version = lines[0].split() 113 | headers = cls.parse_headers("\r\n".join(lines[1:])) 114 | return cls(version=version, uri=uri, method=method, headers=headers) 115 | 116 | def sendto(self, transport, addr): 117 | """ 118 | Send request to a given address via given transport. 119 | 120 | Args: 121 | transport (asyncio.DatagramTransport): 122 | Write transport to send the message on. 123 | addr (Tuple[str, int]): 124 | IP address and port pair to send the message to. 125 | 126 | """ 127 | logger.debug("%s:%s - - %s", *(addr + (self,))) 128 | transport.sendto(bytes(self), addr) 129 | 130 | def __str__(self): 131 | """Return complete SSDP request.""" 132 | lines = list() 133 | lines.append(" ".join([self.method, self.uri, self.version])) 134 | for header in self.headers: 135 | lines.append("%s: %s" % header) 136 | return "\r\n".join(lines) 137 | -------------------------------------------------------------------------------- /ssdp/network.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import sys 3 | 4 | __all__ = [ 5 | "MULTICAST_ADDRESS_IPV4", 6 | "MULTICAST_ADDRESS_IPV6_LINK_LOCAL", 7 | "MULTICAST_ADDRESS_IPV6_SITE_LOCAL", 8 | "MULTICAST_ADDRESS_IPV6_ORG_LOCAL", 9 | "MULTICAST_ADDRESS_IPV6_GLOBAL", 10 | "PORT", 11 | ] 12 | 13 | 14 | MULTICAST_ADDRESS_IPV4 = "239.255.255.250" 15 | MULTICAST_ADDRESS_IPV6_LINK_LOCAL = "ff02::c" 16 | MULTICAST_ADDRESS_IPV6_SITE_LOCAL = "ff05::c" 17 | MULTICAST_ADDRESS_IPV6_ORG_LOCAL = "ff08::c" 18 | MULTICAST_ADDRESS_IPV6_GLOBAL = "ff0e::c" 19 | 20 | PORT = 1900 21 | 22 | 23 | def get_best_family(*address): 24 | """Backport of private `http.server._get_best_family`.""" 25 | family = socket.AF_INET if sys.platform == "win32" else 0 26 | 27 | infos = socket.getaddrinfo( 28 | *address, 29 | family=family, 30 | type=socket.SOCK_STREAM, 31 | flags=socket.AI_PASSIVE, 32 | ) 33 | family, type, proto, canonname, sockaddr = next(iter(infos)) 34 | return family, sockaddr 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/ssdp/ca0cec73374dcaaa16a8e19c1f552fd2ff78319e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(config): 2 | config.addinivalue_line("markers", "cli: skip if selenium is not installed") 3 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | request = b"""NOTIFY * HTTP/1.1 2 | Host: 239.255.255.250:1982 3 | Cache-Control: max-age=3600 4 | Location: yeelight://192.168.1.239:55443 5 | NTS: ssdp:alive 6 | Server: POSIX, UPnP/1.0 YGLC/1 7 | id: 0x000000000015243f 8 | model: color 9 | fw_ver: 18 10 | support: get_prop set_default set_power toggle set_bright start_cf stop_cf set_scene cron_add cron_get cron_del set_ct_abx set_rgb 11 | power: on 12 | bright: 100 13 | color_mode: 2 14 | ct: 4000 15 | rgb: 16711680 16 | hue: 100 17 | sat: 35 18 | name: my_bulb""".replace( 19 | b"\n", b"\r\n" 20 | ) 21 | 22 | response = b"""HTTP/1.1 200 OK 23 | Cache-Control: max-age=3600 24 | Date: 25 | Ext: 26 | Location: yeelight://192.168.1.239:55443 27 | Server: POSIX UPnP/1.0 YGLC/1 28 | id: 0x000000000015243f 29 | model: color 30 | fw_ver: 18 31 | support: get_prop set_default set_power toggle set_bright start_cf stop_cf set_scene cron_add cron_get cron_del set_ct_abx set_rgb 32 | power: on 33 | bright: 100 34 | color_mode: 2 35 | ct: 4000 36 | rgb: 16711680 37 | hue: 100 38 | sat: 35 39 | name: my_bulb""".replace( 40 | b"\n", b"\r\n" 41 | ) 42 | 43 | response_wrong_location = b"""HTTP/1.1 200 OK 44 | Cache-Control: max-age=3600 45 | Date: 46 | Ext: 47 | Location: yeelight://not.an.ip:55443 48 | Server: POSIX UPnP/1.0 YGLC/1""".replace( 49 | b"\n", b"\r\n" 50 | ) 51 | -------------------------------------------------------------------------------- /tests/test_deprecation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ssdp import deprecation 4 | 5 | 6 | def _fn(): 7 | pass 8 | 9 | 10 | def test_moved(): 11 | with pytest.deprecated_call() as warning: 12 | deprecation.moved(_fn)() 13 | 14 | assert ( 15 | str(warning.list[0].message) 16 | == "'ssdp.deprecation._fn' has moved. Please import from: tests.test_deprecation._fn" 17 | ) 18 | -------------------------------------------------------------------------------- /tests/test_lexers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.cli 5 | class TestSSDPLexer: 6 | def test_plugin_registry(self): 7 | pygments = pytest.importorskip("pygments") 8 | lexers = pytest.importorskip("ssdp.lexers") 9 | 10 | assert isinstance(pygments.lexers.get_lexer_by_name("ssdp"), lexers.SSDPLexer) 11 | 12 | def test_tokens(self): 13 | pygments = pytest.importorskip("pygments") 14 | lexers = pytest.importorskip("ssdp.lexers") 15 | 16 | lexer = lexers.SSDPLexer() 17 | text = "M-SEARCH * HTTP/1.1\r\n" "HOST: example.com:1900\r\n" 18 | tokens = list(lexer.get_tokens(text)) 19 | assert tokens == [ 20 | (pygments.token.Token.Name.Function, "M-SEARCH"), 21 | (pygments.token.Token.Text, " "), 22 | (pygments.token.Token.Name.Namespace, "*"), 23 | (pygments.token.Token.Text, " "), 24 | (pygments.token.Token.Keyword.Reserved, "HTTP"), 25 | (pygments.token.Token.Operator, "/"), 26 | (pygments.token.Token.Literal.Number, "1.1"), 27 | (pygments.token.Token.Text, "\n"), 28 | (pygments.token.Token.Name.Attribute, "HOST"), 29 | (pygments.token.Token.Text, ""), 30 | (pygments.token.Token.Operator, ":"), 31 | (pygments.token.Token.Text, " "), 32 | (pygments.token.Token.Literal, "example.com:1900"), 33 | (pygments.token.Token.Text, "\n"), 34 | ] 35 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.cli 7 | class TestDiscover: 8 | def test_help(self): 9 | main = pytest.importorskip("ssdp.__main__") 10 | testing = pytest.importorskip("click.testing") 11 | results = testing.CliRunner().invoke(main.ssdp, ["discover", "--help"]) 12 | assert results.exit_code == 0 13 | assert "Usage: ssdp discover [OPTIONS]" in results.output 14 | 15 | def test_call(self): 16 | main = pytest.importorskip("ssdp.__main__") 17 | testing = pytest.importorskip("click.testing") 18 | results = testing.CliRunner().invoke(main.ssdp, ["discover", "--max-wait", "1"]) 19 | assert results.exit_code == 0 20 | assert "ssdp:all" in results.output 21 | 22 | def test_call_w_search_target(self): 23 | main = pytest.importorskip("ssdp.__main__") 24 | testing = pytest.importorskip("click.testing") 25 | results = testing.CliRunner().invoke( 26 | main.ssdp, ["discover", "--max-wait", "1", "--search-target", "ssdp:dial"] 27 | ) 28 | assert results.exit_code == 0 29 | assert "ssdp:dial" in results.output 30 | 31 | 32 | @pytest.mark.skipif( 33 | importlib.util.find_spec("pygments") is not None, reason="cli is installed" 34 | ) 35 | def test_import_warning(): 36 | with pytest.raises(ImportError) as e: 37 | importlib.import_module("ssdp.__main__") 38 | 39 | assert ( 40 | "The SSDP CLI requires needs to be installed via `pip install ssdp[cli]`." 41 | in str(e.value) 42 | ) 43 | -------------------------------------------------------------------------------- /tests/test_messages.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from ssdp import network 6 | from ssdp.messages import SSDPMessage, SSDPRequest, SSDPResponse 7 | 8 | from . import fixtures 9 | 10 | 11 | class TestSSDPMessage: 12 | def test_headers_copy(self): 13 | headers = [("Cache-Control", "max-age=3600")] 14 | msg = SSDPMessage(headers=headers) 15 | assert msg.headers == headers 16 | assert msg.headers is not headers 17 | 18 | def test_headers_dict(self): 19 | headers = {"Cache-Control": "max-age=3600"} 20 | msg = SSDPMessage(headers=headers) 21 | assert msg.headers == [("Cache-Control", "max-age=3600")] 22 | 23 | def test_headers_none(self): 24 | msg = SSDPMessage(headers=None) 25 | assert msg.headers == [] 26 | 27 | def test_parse(self): 28 | assert isinstance(SSDPMessage.parse("HTTP/1.1 200 OK"), SSDPResponse) 29 | assert isinstance(SSDPMessage.parse("NOTIFY * HTTP/1.1"), SSDPRequest) 30 | 31 | def test_parse_headers(self): 32 | headers = SSDPMessage.parse_headers("Cache-Control: max-age=3600") 33 | assert headers == [("Cache-Control", "max-age=3600")] 34 | 35 | def test_str(self): 36 | with pytest.raises(NotImplementedError): 37 | str(SSDPMessage()) 38 | 39 | def test_bytes(self): 40 | class MyMessage(SSDPMessage): 41 | def __str__(self): 42 | return "NOTIFY * HTTP/1.1\r\n" "Cache-Control: max-age=3600" 43 | 44 | msg = MyMessage() 45 | assert bytes(msg) == ( 46 | b"NOTIFY * HTTP/1.1\r\nCache-Control: max-age=3600\r\n\r\n" 47 | ) 48 | 49 | 50 | class TestSSDPResponse: 51 | def test_parse(self): 52 | response = SSDPResponse.parse(fixtures.response.decode()) 53 | assert response.status_code == 200 54 | assert response.reason == "OK" 55 | 56 | def test_str(self): 57 | response = SSDPResponse( 58 | 200, "OK", headers=[("Location", "http://192.168.1.239:55443")] 59 | ) 60 | assert str(response) == ( 61 | "HTTP/1.1 200 OK\r\nLocation: http://192.168.1.239:55443" 62 | ) 63 | 64 | def test_sendto(self): 65 | transport = Mock() 66 | addr = network.MULTICAST_ADDRESS_IPV4, network.PORT 67 | SSDPResponse( 68 | 200, "OK", headers=[("Location", "http://192.168.1.239:55443")] 69 | ).sendto(transport, addr) 70 | transport.sendto.assert_called_once_with( 71 | b"HTTP/1.1 200 OK\r\nLocation: http://192.168.1.239:55443\r\n\r\n", addr 72 | ) 73 | 74 | 75 | class TestSSDPRequest: 76 | def test_parse(self): 77 | request = SSDPRequest.parse(fixtures.request.decode()) 78 | assert request.method == "NOTIFY" 79 | assert request.uri == "*" 80 | 81 | def test_str(self): 82 | request = SSDPRequest( 83 | "NOTIFY", "*", headers=[("Cache-Control", "max-age=3600")] 84 | ) 85 | assert str(request) == ("NOTIFY * HTTP/1.1\r\nCache-Control: max-age=3600") 86 | 87 | def test_sendto(self): 88 | transport = Mock() 89 | addr = network.MULTICAST_ADDRESS_IPV4, network.PORT 90 | SSDPRequest("NOTIFY", "*").sendto(transport, addr) 91 | transport.sendto.assert_called_once_with(b"NOTIFY * HTTP/1.1\r\n\r\n", addr) 92 | -------------------------------------------------------------------------------- /tests/test_network.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import sys 3 | 4 | import pytest 5 | 6 | from ssdp import network 7 | 8 | 9 | def test_get_best_family(): 10 | assert network.get_best_family("::", 1900) == ( 11 | socket.AF_INET6, 12 | ("::", 1900, 0, 0), 13 | ) 14 | 15 | 16 | @pytest.mark.skipif(sys.platform != "win32", reason="tests for windows only") 17 | def test_get_best_family__win32(): 18 | assert network.get_best_family(None, 1900) == ( 19 | socket.AF_INET, 20 | ("0.0.0.0", 1900, 0, 0), 21 | ) 22 | --------------------------------------------------------------------------------