├── .dockerignore ├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile.micropython.1.24.esp32 ├── Dockerfile.micropython.1.24.rp2 ├── LICENSE ├── Makefile ├── README.md ├── REFERENCE.md ├── build-and-copy-firmware.sh ├── config ├── boards-after-micropython-1-20 │ ├── MDNS │ │ ├── manifest.py │ │ ├── mpconfigboard.cmake │ │ ├── mpconfigboard.h │ │ └── mpconfigboard.mk │ └── sdkconfig.mdns └── boards │ ├── MDNS │ ├── mpconfigboard.cmake │ ├── mpconfigboard.h │ └── mpconfigboard.mk │ └── sdkconfig.mdns ├── examples ├── README.md ├── request_a_record.py ├── service_discovery_constant.py ├── service_discovery_once.py └── service_responder.py ├── generate-package-json.py ├── images ├── service-discovery.gif └── service-discovery.rec ├── package.json ├── pyproject.toml ├── requirements.txt └── src ├── README.md ├── mdns_client ├── __init__.py ├── client.py ├── constants.py ├── parser.py ├── responder.py ├── service_discovery │ ├── __init__.py │ ├── discovery.py │ ├── service_monitor.py │ ├── service_response.py │ └── txt_discovery.py ├── structs.py └── util.py └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | firmware.bin 2 | firmware.*.bin 3 | venv 4 | .ipynb_checkpoints 5 | main.py 6 | dist 7 | __pycache__ 8 | *.pyc 9 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | #ignore = E203, E266, E501, W503, F403, F401 3 | ignore = F821 4 | max-line-length = 120 5 | max-complexity = 20 6 | select = B,C,E,F,W,T4,B9 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | pytestdebug.log 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | doc/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | pythonenv* 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # profiling data 143 | .prof 144 | 145 | # End of https://www.toptal.com/developers/gitignore/api/python 146 | 147 | firmware.bin 148 | firmware.*.bin 149 | firmware.uf2 150 | firmware.*.uf2 151 | tmp 152 | main.py 153 | src/MANIFEST 154 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: "test.*py" 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: 24.10.0 5 | hooks: 6 | - id: black 7 | language_version: python3.10 8 | - repo: https://github.com/hakancelikdev/unimport 9 | rev: "1.2.1" 10 | hooks: 11 | - id: unimport 12 | args: ["-r", "--exclude", "(__init__.py)|venv|env"] 13 | - repo: https://github.com/MarcoGorelli/absolufy-imports 14 | rev: v0.3.1 15 | hooks: 16 | - id: absolufy-imports 17 | - repo: https://github.com/pycqa/flake8 18 | rev: "7.1.1" 19 | hooks: 20 | - id: flake8 21 | - repo: https://github.com/pre-commit/pre-commit-hooks 22 | rev: v5.0.0 23 | hooks: 24 | - id: check-toml 25 | - id: end-of-file-fixer 26 | - repo: https://github.com/PyCQA/isort 27 | rev: "5.13.2" 28 | hooks: 29 | - id: isort 30 | language_version: python3.10 31 | additional_dependencies: [toml] 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v1.6.0 (2025-01-19) 4 | 5 | ### Version removal 6 | 7 | - Remove supported and build Micropython versions and only support 1.24 to avoid needing to build compatibility hacks for previous versions. 8 | 9 | ### Bug fixes 10 | 11 | - Fix builds for RP2 in Dockerfile. 12 | ([`26629f`](https://github.com/cbrand/micropython-mdns/commit/26629f326665a45835d5a59f796d4b3382b89e94)) 13 | 14 | Updated the sed command by @joncard1 in https://github.com/cbrand/micropython-mdns/pull/30 15 | 16 | - Fix MDNS run in Micropython 1.24 on RP2 17 | ([`6d09fb`](https://github.com/cbrand/micropython-mdns/commit/6d09fbacc50b75e31773378f2ec098351b3bfc99)) 18 | 19 | ## v1.5.2 (2024-12-15) 20 | 21 | ### Bug Fixes 22 | 23 | - **service_discovery**: A record detection for certain devices 24 | ([`078f3df`](https://github.com/cbrand/micropython-mdns/commit/078f3df5cb44b4438049a26431ae09aea48b7624)) 25 | 26 | Ensure that the A record for certain devices (for example shellies) is done correctly by buffering 27 | currently irrelevant A records as they get sent before the SRV record for the specific target is 28 | sent. 29 | 30 | This change slightly increases memory overhead as an additional configurable buffer needs to be 31 | added to allow buffering A records which are not yet relevant for an SRV response. 32 | 33 | - **service_discovery**: Adjust discover_once to shut down resources 34 | ([`2331707`](https://github.com/cbrand/micropython-mdns/commit/233170773933aa2de63b10cedbe3daf9392dff15)) 35 | 36 | If the discover_once method is called and the underyling client and / or discovery has not been 37 | started by another component ensure to shut down the complete library afterwards to not consume 38 | any resources without a developer explicitly asking for other functionality in the MDNS library. 39 | 40 | ### Chores 41 | 42 | - Update Makefile 43 | ([`1503bfa`](https://github.com/cbrand/micropython-mdns/commit/1503bfa0cfed6203b41594c931ca0c0411957790)) 44 | 45 | fix various configs in the Makefile and add Micropython 1.24 (hacked) support. 46 | 47 | 48 | ## v1.5.1 (2024-12-15) 49 | 50 | ### Bug Fixes 51 | 52 | - Requirements.txt to reduce vulnerabilities 53 | ([`819b300`](https://github.com/cbrand/micropython-mdns/commit/819b300355cd21bac6092e87fa30051a87f249f2)) 54 | 55 | The following vulnerabilities are fixed by pinning transitive dependencies: - 56 | https://snyk.io/vuln/SNYK-PYTHON-ZIPP-7430899 57 | 58 | - **Docker**: Fix build files for rp2 versions 59 | ([`2ed23d4`](https://github.com/cbrand/micropython-mdns/commit/2ed23d457a691656ecab3e4ea824b6cf10cbecba)) 60 | 61 | copy correct file over with the correct configuration. 62 | 63 | - **service_discovery**: Stop discovery loop on stopped client 64 | ([`5bf8419`](https://github.com/cbrand/micropython-mdns/commit/5bf8419e1b0b5fa59da48198f8b20b6624477520)) 65 | 66 | The service discovery did not take into account that the client might have shut down in between, 67 | resulting in a loop running forever reinitializing manually closed clients resulting in 68 | unexplainable error messages. 69 | 70 | ### Chores 71 | 72 | - **Dockerfile**: Add support for rp2 build for micropython 1.22 73 | ([`e009cfb`](https://github.com/cbrand/micropython-mdns/commit/e009cfbd276734bd5754b36da8ea8e1e35488974)) 74 | 75 | - **Dockerfile**: Fix esp32 buidls for 1.21 and 1.22 to actually build versions instead of 1.23.0 76 | ([`50cff7e`](https://github.com/cbrand/micropython-mdns/commit/50cff7efc94506fbf3645b5e176d74de521ecad5)) 77 | 78 | - **Dockerfile**: Test fix for rp2 according to #26 79 | ([`5906dd5`](https://github.com/cbrand/micropython-mdns/commit/5906dd517753397d3351d9e3da6b0591d81589d8)) 80 | 81 | ### Documentation 82 | 83 | - Clarify on asyncio tasks implicitly started via query_once() 84 | ([`d66f00d`](https://github.com/cbrand/micropython-mdns/commit/d66f00d503da2583478a8f6e4ee1873934cce897)) 85 | 86 | 87 | ## v1.5.0 (2024-10-15) 88 | 89 | ### Bug Fixes 90 | 91 | - Requirements.txt to reduce vulnerabilities 92 | ([`1e3d040`](https://github.com/cbrand/micropython-mdns/commit/1e3d040c2a89220e97a2ce2949d8073d2aeaa236)) 93 | 94 | The following vulnerabilities are fixed by pinning transitive dependencies: - 95 | https://snyk.io/vuln/SNYK-PYTHON-ZIPP-7430899 96 | 97 | - Requirements.txt to reduce vulnerabilities 98 | ([`56bbed8`](https://github.com/cbrand/micropython-mdns/commit/56bbed8532a68da66f7a4df731156d5be1282691)) 99 | 100 | The following vulnerabilities are fixed by pinning transitive dependencies: - 101 | https://snyk.io/vuln/SNYK-PYTHON-URLLIB3-7267250 102 | 103 | - Requirements.txt to reduce vulnerabilities 104 | ([`e8d5e59`](https://github.com/cbrand/micropython-mdns/commit/e8d5e59539cf0720429a2fbd1cff3890857e2b13)) 105 | 106 | The following vulnerabilities are fixed by pinning transitive dependencies: - 107 | https://snyk.io/vuln/SNYK-PYTHON-URLLIB3-7267250 108 | 109 | - Requirements.txt to reduce vulnerabilities 110 | ([`f4f3f29`](https://github.com/cbrand/micropython-mdns/commit/f4f3f2973c6804511289c1e2682650ad95e02add)) 111 | 112 | The following vulnerabilities are fixed by pinning transitive dependencies: - 113 | https://snyk.io/vuln/SNYK-PYTHON-REQUESTS-6928867 114 | 115 | - Requirements.txt to reduce vulnerabilities 116 | ([`8e53ff8`](https://github.com/cbrand/micropython-mdns/commit/8e53ff828fef564ca1ecf2d0e38026033b08b0be)) 117 | 118 | The following vulnerabilities are fixed by pinning transitive dependencies: - 119 | https://snyk.io/vuln/SNYK-PYTHON-CERTIFI-3164749 - 120 | https://snyk.io/vuln/SNYK-PYTHON-CERTIFI-5805047 - 121 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3172287 - 122 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3314966 - 123 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3315324 - 124 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3315328 - 125 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3315331 - 126 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3315452 - 127 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3315972 - 128 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3315975 - 129 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3316038 - 130 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-3316211 - 131 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-5663682 - 132 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-5777683 - 133 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-5813745 - 134 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-5813746 - 135 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-5813750 - 136 | https://snyk.io/vuln/SNYK-PYTHON-CRYPTOGRAPHY-5914629 - 137 | https://snyk.io/vuln/SNYK-PYTHON-PYGMENTS-1086606 - 138 | https://snyk.io/vuln/SNYK-PYTHON-PYGMENTS-1088505 - 139 | https://snyk.io/vuln/SNYK-PYTHON-PYGMENTS-5750273 - 140 | https://snyk.io/vuln/SNYK-PYTHON-REQUESTS-5595532 - 141 | https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-3180412 - 142 | https://snyk.io/vuln/SNYK-PYTHON-WHEEL-3180413 143 | 144 | - **record**: Lowercase all mdns records 145 | ([`f557fa0`](https://github.com/cbrand/micropython-mdns/commit/f557fa05d7d3226931222c2d7ddf2adb21780d78)) 146 | 147 | Following advice from https://github.com/cbrand/micropython-mdns/issues/24 make sure that only 148 | lowercase configurations are resolved making the library case insensitive. 149 | 150 | ### Chores 151 | 152 | - **pre-commit-config**: Update pre commit versions 153 | ([`f3fae99`](https://github.com/cbrand/micropython-mdns/commit/f3fae99dd6e8aea9393c28d1940ca9f943b2ccd3)) 154 | 155 | - **README**: Add tips how to build RPI Pico with the library 156 | ([`ca5a95a`](https://github.com/cbrand/micropython-mdns/commit/ca5a95ac6f890b078ae9731ab1f5ac4e54367457)) 157 | 158 | ### Features 159 | 160 | - **boards**: Add compile support for raspberry pi micro 161 | ([`86670ac`](https://github.com/cbrand/micropython-mdns/commit/86670ac4041329a668b6cc63e7b3399339fa0977)) 162 | 163 | Add support for RPI builds and support for Micropython 1.21, 1.22 and 1.23 for esp32. 164 | 165 | Fixes #15 166 | 167 | 168 | ## v1.4.0 (2023-09-21) 169 | 170 | ### Chores 171 | 172 | - Add support for building on remote docker 173 | ([`d8cb40f`](https://github.com/cbrand/micropython-mdns/commit/d8cb40f0a9684869db3b5ce45f57bb6b2e779aea)) 174 | 175 | Adjust build scripts to work to retrieve the firmware on remote docker builds too. 176 | 177 | - Drop support for Micropython 1.13 178 | ([`6f8c186`](https://github.com/cbrand/micropython-mdns/commit/6f8c186b5a4c045a9f02bc1844bece88efd85485)) 179 | 180 | Requires python 2 to build which is no longer supported. 181 | 182 | - Fix semantic release config for new version 183 | ([`8ffb6f4`](https://github.com/cbrand/micropython-mdns/commit/8ffb6f4a35c5109185cda9940c8239acaecc0f95)) 184 | 185 | - Update Changelog with 1.3.0 changes 186 | ([`dcbf580`](https://github.com/cbrand/micropython-mdns/commit/dcbf580304e0ce1fb54a3e3b0d3594350ea2fd00)) 187 | 188 | - Update classifiers for pypi 189 | ([`159b7aa`](https://github.com/cbrand/micropython-mdns/commit/159b7aa4a202f6dd9ff3443d0add56f328ea8053)) 190 | 191 | - Update readme with compile targets 192 | ([`482148d`](https://github.com/cbrand/micropython-mdns/commit/482148d5d578cf62ebc90df1f9e08a1d6d5bb33f)) 193 | 194 | ### Features 195 | 196 | - Add mip package support 197 | ([`f7d6299`](https://github.com/cbrand/micropython-mdns/commit/f7d62992a0198db448fb431b9db90b9b3147cb5c)) 198 | 199 | Following configuration for 200 | https://docs.micropython.org/en/latest/reference/packages.html#writing-publishing-packages 201 | 202 | 203 | ## v1.3.0 (2023-09-20) 204 | 205 | ### Bug Fixes 206 | 207 | - Make all imports absolute 208 | ([`bafe176`](https://github.com/cbrand/micropython-mdns/commit/bafe17626411ed934b10ba4dd7867d7c7187364c)) 209 | 210 | Fix which might help using the library in a frozen module. 211 | 212 | - Requirements.txt to reduce vulnerabilities 213 | ([`c717474`](https://github.com/cbrand/micropython-mdns/commit/c7174746614458885f48d1f471e226b79c347871)) 214 | 215 | The following vulnerabilities are fixed by pinning transitive dependencies: - 216 | https://snyk.io/vuln/SNYK-PYTHON-WHEEL-3092128 - https://snyk.io/vuln/SNYK-PYTHON-WHEEL-3180413 217 | 218 | ### Chores 219 | 220 | - Fix Dockerfile configuration 221 | ([`89f2e58`](https://github.com/cbrand/micropython-mdns/commit/89f2e58d8b02f6714985b080a74d3a0986a17770)) 222 | 223 | Remove the python installations in all Dockerfiles to allow building the project with newer docker 224 | files. 225 | 226 | - Fix Makefile for other configs 227 | ([`c2bb1e7`](https://github.com/cbrand/micropython-mdns/commit/c2bb1e72486b191798b52e626c0475c9f414caa4)) 228 | 229 | Make tty interface configurable 230 | 231 | - Update documentation for new advertise function 232 | ([`3b969b8`](https://github.com/cbrand/micropython-mdns/commit/3b969b8dab64ea33f92ea1920cdc02ce74b45529)) 233 | 234 | Add documentation to show how service_host_name in the advertise endpoint works. 235 | 236 | ### Features 237 | 238 | - Add build for micropython 1.20 239 | ([`01b7996`](https://github.com/cbrand/micropython-mdns/commit/01b7996ac22db7879a22e86f21807259b617d125)) 240 | 241 | - Add support for configurable service hostnames 242 | ([`cc6169f`](https://github.com/cbrand/micropython-mdns/commit/cc6169ff06734befc5e042be6ee7768c8ff60904)) 243 | 244 | Instead of fixing the service host name to the hostname of the host, make it possible to add a new 245 | parameter `host` into the `advertise` function of the service and allow it to register its own 246 | advertised name in the service. 247 | 248 | Usage example: ``` loop = uasyncio.get_event_loop() client = Client(own_ip_address) responder = 249 | Responder( client, own_ip=lambda: own_ip_address, host=lambda: 250 | "my-awesome-microcontroller-{}".format(responder.generate_random_postfix()), ) 251 | 252 | def announce_service(): responder.advertise("_myawesomeservice", "_tcp", port=12345, data={"some": 253 | "metadata", "for": ["my", "service"]}, service_host_name="myoverwrittenhost") ``` 254 | 255 | 256 | ## v1.2.3 (2022-10-04) 257 | 258 | ### Bug Fixes 259 | 260 | - Always return a txt dns record even if empty 261 | ([`e75757a`](https://github.com/cbrand/micropython-mdns/commit/e75757a024582221c66ac5f2832d040bfaa0dc4b)) 262 | 263 | 264 | ## v1.2.2 (2022-10-04) 265 | 266 | ### Bug Fixes 267 | 268 | - Skip null txt record 269 | ([`40d09b7`](https://github.com/cbrand/micropython-mdns/commit/40d09b7ad1bb5e785b7c8be3f04ac923043545ca)) 270 | 271 | Do not try to send a txt record when information for sending it out is missing. Before this if there 272 | was a TXT record request it did raise an Exception instead of just not returning the information. 273 | 274 | ### Chores 275 | 276 | - Adjust reference example for better use 277 | ([`e7b1087`](https://github.com/cbrand/micropython-mdns/commit/e7b10874a95e1e99006dd2f0a3909fbeb042a1ae)) 278 | 279 | Use the generate_random_postfix call outside of the responder to let the example generate a 280 | consistent postfix for each request. 281 | 282 | - Remove confusing test folder 283 | ([`7ab609e`](https://github.com/cbrand/micropython-mdns/commit/7ab609e4be51e0927db5dd2938e0ede3d617cabe)) 284 | 285 | 286 | ## v1.2.1 (2022-10-03) 287 | 288 | ### Bug Fixes 289 | 290 | - Correct name length package generation 291 | ([`9a9ddae`](https://github.com/cbrand/micropython-mdns/commit/9a9ddae780d3b34f29e275e724442c95d769d575)) 292 | 293 | Fixed an issue with name length generation for domain names which resulted into micropython-mdns 294 | creating wrongly formatted dns packages which couldn't be parsed by certain implementations of 295 | mdns like avahi. 296 | 297 | Fixes #6 298 | 299 | 300 | ## v1.2.0 (2022-09-28) 301 | 302 | ### Bug Fixes 303 | 304 | - Adjust release pipeline 305 | ([`1f96cf1`](https://github.com/cbrand/micropython-mdns/commit/1f96cf1ca2adb769b40d8d838165f5819c07f519)) 306 | 307 | - Responder dns ptr record zeroconf support 308 | ([`285d24b`](https://github.com/cbrand/micropython-mdns/commit/285d24b3b339a5c6fa9f46db4ae26129fe2a491e)) 309 | 310 | fixes #5 311 | 312 | ### Chores 313 | 314 | - Adjust pyproject 315 | ([`e38a376`](https://github.com/cbrand/micropython-mdns/commit/e38a376bb52a9b13aa3a69bb18bdcd5b9fbe87e3)) 316 | 317 | - Fix changelog for 1.1.0 318 | ([`024d33f`](https://github.com/cbrand/micropython-mdns/commit/024d33fd205b80fd4d971d5a77c3adac30304993)) 319 | 320 | - Wording adjustments README 321 | ([`8ac3435`](https://github.com/cbrand/micropython-mdns/commit/8ac343545f34ea915ef43190c5f3347315d7e57e)) 322 | 323 | ### Features 324 | 325 | - Add compile targets for new micropython 326 | ([`2b15d01`](https://github.com/cbrand/micropython-mdns/commit/2b15d0198562b2a28537ffb4c7cdd822385e16ea)) 327 | 328 | Add support for micropython 1.18 and 1.19 329 | 330 | 331 | ## v1.1.0 (2022-01-06) 332 | 333 | ### Chores 334 | 335 | - Add semantic release support 336 | ([`6aa4325`](https://github.com/cbrand/micropython-mdns/commit/6aa43258f8daba88d2c0c3f5c9b1328eed34f296)) 337 | 338 | ### Features 339 | 340 | - Add image for micropython 1.16 and 1.17 341 | ([`525ec39`](https://github.com/cbrand/micropython-mdns/commit/525ec3964ea6f5941ffa5032a330aaf6658f114d)) 342 | 343 | 344 | ## v1.0.1 (2021-06-15) 345 | 346 | ### Chores 347 | 348 | - **client**: Reinitialize socket on send failure 349 | ([`52b3ff9`](https://github.com/cbrand/micropython-mdns/commit/52b3ff9396880fdda81d54c32506b452c7ab3998)) 350 | 351 | 352 | ## v1.0.0 (2021-04-26) 353 | 354 | ### Chores 355 | 356 | - **examples**: Explicitly set wlan active flag 357 | ([`c7d0168`](https://github.com/cbrand/micropython-mdns/commit/c7d01682ad0fae39f9d6b1edd58b9b7380b18168)) 358 | 359 | - **pre-commit**: Update pre-commit config 360 | ([`50305a5`](https://github.com/cbrand/micropython-mdns/commit/50305a5f9ed399200e759fe6e451620dfe88693c)) 361 | 362 | - **README**: Add reference to MicroPython 1.15 363 | ([`549c594`](https://github.com/cbrand/micropython-mdns/commit/549c594c19f700ed35b2809f7364cb98db491bff)) 364 | 365 | 366 | ## v0.9.3 (2021-01-06) 367 | 368 | 369 | ## v0.9.2 (2021-01-06) 370 | 371 | ### Bug Fixes 372 | 373 | - **client**: Do not fail on error in packet processing 374 | ([`8fe0203`](https://github.com/cbrand/micropython-mdns/commit/8fe0203b1cc903eaa76c629ec28906c27f64ac99)) 375 | 376 | 377 | ## v0.9.1 (2021-01-06) 378 | 379 | 380 | ## v0.9.0 (2021-01-06) 381 | 382 | ### Bug Fixes 383 | 384 | - **client**: Handle memory issues on high traffic MDNS networks 385 | ([`8ee9488`](https://github.com/cbrand/micropython-mdns/commit/8ee94882d81d5bc17388ac4f75d7ee9ce59f816b)) 386 | 387 | - **parser**: Add possibility for nested name dereference 388 | ([`92f5b46`](https://github.com/cbrand/micropython-mdns/commit/92f5b4633e0428da9bcf72c69284a02acc468a50)) 389 | 390 | ### Chores 391 | 392 | - **debug**: Add better log debugging 393 | ([`03997cb`](https://github.com/cbrand/micropython-mdns/commit/03997cbebb60c318c80962ce8896c246f32c5eb9)) 394 | 395 | - **examples**: Add examples for library usage 396 | ([`eb1ac35`](https://github.com/cbrand/micropython-mdns/commit/eb1ac3502af4918ef522f76932e8f013f8141611)) 397 | 398 | - **REFERENCE**: Adjust shield colors 399 | ([`baf9589`](https://github.com/cbrand/micropython-mdns/commit/baf9589c13ec489284e91cafdc70aded7137b754)) 400 | -------------------------------------------------------------------------------- /Dockerfile.micropython.1.24.esp32: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/x86_64 python:3.11 2 | 3 | ARG ESP_IDF_VERSION=v5.2.2 4 | ARG MICROPYTHON_VERSION=v1.24.1 5 | ARG MICROPYTHON_FILE_PATH=1.24 6 | 7 | ENV MICROPYTHON_FILE_PATH=${MICROPYTHON_FILE_PATH} 8 | 9 | RUN apt-get update && \ 10 | apt-get install -y git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0 quilt 11 | 12 | WORKDIR /opt/app 13 | 14 | ENV PATH=${PATH}:/root/.local/bin 15 | 16 | RUN git clone https://github.com/micropython/micropython && \ 17 | git clone -b ${ESP_IDF_VERSION} --recursive https://github.com/espressif/esp-idf.git && \ 18 | cd /opt/app/micropython && \ 19 | git checkout ${MICROPYTHON_VERSION} && \ 20 | git submodule update --init && \ 21 | pip install -U pip && \ 22 | pip install pyparsing==2.3.1 && \ 23 | pip install esptool==3.0 && \ 24 | pip install pyserial==3.5 && \ 25 | pip install 'click>=7.0' \ 26 | 'future>=0.15.2' \ 27 | 'pyelftools>=0.22' \ 28 | 'idf-component-manager~=1.2' \ 29 | 'urllib3<2' \ 30 | 'pygdbmi<=0.9.0.2' \ 31 | 'kconfiglib==13.7.1' \ 32 | 'bitstring>=3.1.6,<4' \ 33 | 'construct==2.10.54' && \ 34 | mkdir -p /tmp/mdns-build 35 | 36 | ENV ESPIDF "/opt/app/esp-idf" 37 | ENV IDF_PATH "/opt/app/esp-idf" 38 | ENV BOARD "MDNS" 39 | 40 | WORKDIR ${ESPIDF} 41 | RUN ./install.sh esp32 42 | 43 | ADD config/boards-after-micropython-1-20/ /opt/app/micropython/ports/esp32/boards/ 44 | 45 | WORKDIR /opt/app/micropython 46 | 47 | RUN make -C mpy-cross 48 | 49 | WORKDIR /opt/app/micropython/ports/esp32 50 | 51 | # WARNING: This is a special fix for ESP32 and only Micropython 1.24.0 - 1.24.1 as disabling MDNS otherwise fails on the builds 52 | # See: https://github.com/micropython/micropython/pull/16349 for details 53 | RUN git checkout b20687d0e71360bf76eccedbb9d2c1f73697693a 54 | 55 | RUN bash -c "source ${IDF_PATH}/export.sh && make submodules && make" 56 | 57 | ADD src/mdns_client modules/mdns_client 58 | 59 | ENTRYPOINT [ "bash" ] 60 | 61 | CMD ["-c", "source ${IDF_PATH}/export.sh && make && cp build-MDNS/firmware.bin /tmp/mdns-build/firmware.mp.${MICROPYTHON_FILE_PATH}.esp32.bin"] 62 | -------------------------------------------------------------------------------- /Dockerfile.micropython.1.24.rp2: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/x86_64 python:3.11 2 | 3 | ARG MICROPYTHON_VERSION=v1.24.1 4 | ARG MICROPYTHON_FILE_PATH=1.24 5 | ARG BOARD=RPI_PICO_W 6 | 7 | RUN apt-get update && \ 8 | apt-get upgrade -y && \ 9 | apt-get install -y cmake build-essential libffi-dev git pkg-config gcc-arm-none-eabi -y 10 | 11 | WORKDIR /opt/app 12 | 13 | ENV PATH=${PATH}:/root/.local/bin 14 | 15 | RUN git clone https://github.com/micropython/micropython && \ 16 | cd /opt/app/micropython && \ 17 | git checkout ${MICROPYTHON_VERSION} && \ 18 | git submodule update --init && \ 19 | pip install -U pip && \ 20 | pip install pyparsing==2.3.1 && \ 21 | pip install esptool==3.0 && \ 22 | pip install pyserial==3.5 && \ 23 | pip install 'click>=7.0' \ 24 | 'future>=0.15.2' \ 25 | 'pyelftools>=0.22' \ 26 | 'idf-component-manager~=1.2' \ 27 | 'urllib3<2' \ 28 | 'pygdbmi<=0.9.0.2' \ 29 | 'kconfiglib==13.7.1' \ 30 | 'bitstring>=3.1.6,<4' \ 31 | 'construct==2.10.54' && \ 32 | mkdir -p /tmp/mdns-build 33 | 34 | WORKDIR /opt/app/micropython 35 | 36 | RUN sed -i -E 's/define LWIP_MDNS_RESPONDER([ ]+)1/define LWIP_MDNS_RESPONDER\10/g' ports/rp2/lwip_inc/lwipopts.h 37 | 38 | ENV BOARD=${BOARD} 39 | ENV MICROPYTHON_FILE_PATH=${MICROPYTHON_FILE_PATH} 40 | 41 | RUN make -C mpy-cross 42 | 43 | RUN git submodule update --init -- lib/pico-sdk lib/tinyusb 44 | 45 | WORKDIR /opt/app/micropython/ports/rp2 46 | 47 | ADD src/mdns_client modules/mdns_client 48 | 49 | ENV MICROPYTHON_FILE_PATH=${MICROPYTHON_FILE_PATH} 50 | 51 | ENTRYPOINT [ "bash" ] 52 | 53 | CMD ["-c", "make clean && make submodules && make -j16 && cp build-${BOARD}/firmware.uf2 /tmp/mdns-build/firmware.mp.${MICROPYTHON_FILE_PATH}.rp2.uf2"] 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2021 Christoph Brand 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build-compile 2 | 3 | build-compile: build compile 4 | TTY_PORT?=/dev/ttyUSB0 5 | PWD?=$(shell pwd) 6 | DNS_VOLUME_NAME?=mdns-build-volume 7 | NEWEST_MICROPYTHON_VERSION?=1.24 8 | 9 | erase: 10 | esptool.py --chip esp32 --port ${TTY_PORT} erase_flash 11 | 12 | flash: 13 | esptool.py --chip esp32 --port ${TTY_PORT} write_flash -z 0x1000 firmware.bin 14 | 15 | copy-main: 16 | ampy -p ${TTY_PORT} put main.py /main.py 17 | 18 | copy: copy-main 19 | 20 | create-data-volume: 21 | docker volume create ${DNS_VOLUME_NAME} || true 22 | 23 | compile-micropython-1-24: compile-micropython-esp32-1-24 compile-micropython-rp2-1-24 24 | 25 | compile-micropython-esp32-1-24: 26 | MICROPYTHON_VERSION=1.24 MICROPYTHON_PORT=esp32 ./build-and-copy-firmware.sh 27 | 28 | compile-micropython-rp2-1-24: 29 | MICROPYTHON_VERSION=1.24 MICROPYTHON_PORT=rp2 ./build-and-copy-firmware.sh 30 | 31 | 32 | compile-newest: compile-micropython-esp32-1-24 33 | docker cp helper:/data/firmware.mp.${NEWEST_MICROPYTHON_VERSION}.esp32.bin ./firmware.bin 34 | docker rm helper 35 | 36 | compile: compile-micropython-1-24 37 | 38 | install: erase compile-newest flash copy-main 39 | 40 | 41 | micropython-build-shell: compile-micropython-1-24 42 | docker run --rm -t esp32-mdns-client:micropython.1.24.esp32 bash 43 | 44 | 45 | compile-and-flash: compile-newest flash 46 | 47 | compile-and-shell: compile-and-flash shell 48 | 49 | shell: 50 | picocom ${TTY_PORT} -b115200 51 | 52 | build-and-upload: build upload 53 | 54 | mip-json: 55 | python generate-package-json.py 56 | 57 | build: mip-json 58 | rm -rf src/dist/*.tar.gz* 59 | cd src && python setup.py sdist 60 | 61 | upload: 62 | twine upload src/dist/*.tar.gz 63 | 64 | generatecligif: 65 | docker run --rm -t -u $$(id -u) -v $(CURDIR):/data asciinema/asciicast2gif -w 116 -h 20 images/service-discovery.rec images/service-discovery.gif 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Micropython MDNS 2 | 3 | [![PyPi](https://img.shields.io/pypi/v/micropython-mdns) ![MIT licensed](https://img.shields.io/pypi/l/micropython-mdns)](https://pypi.org/project/micropython-mdns/) 4 | 5 | ![Image showing service discovery](https://raw.githubusercontent.com/cbrand/micropython-mdns/main/images/service-discovery.gif) 6 | 7 | A pure Python implementation of [MDNS](https://tools.ietf.org/html/rfc6762) and the [Service Discovery](https://tools.ietf.org/html/rfc6763) protocol over MDNS 8 | for [MicroPython](https://micropython.org/). 9 | 10 | ## Intended Audience 11 | 12 | You should not use this library if you "just" require MDNS A record lookup and Host annoucement if there is already baked in support in your MicroPython distribution. 13 | This is for example the case with the default ESP32 MicroPython distribution since v1.12. This will be in all cases more resource efficient. 14 | 15 | If you, however, require additional functionality like Service Discovery and Annoucement, you should use this library. It supports all functionality of existing 16 | basic MDNS implementations plus these features. You will not loose any functionality. 17 | 18 | ## Installation 19 | 20 | You can use the new [`mip`](https://docs.micropython.org/en/latest/reference/packages.html#installing-packages-with-mip) package manager: 21 | 22 | ```python 23 | import mip 24 | mip.install("github:cbrand/micropython-mdns") 25 | ``` 26 | 27 | For using this library, native C type implementations of MDNS which use the MDNS service port need to be disabled. For example, this project has been developed 28 | on the ESP32 which MicroPython implementation per default has a basic MDNS implementation available. This does only support local A record lookups and A record 29 | responding of its own host address. 30 | 31 | The [releases page](https://github.com/cbrand/micropython-mdns/releases) on this project publishes a firmware.mp.1.24.esp32.bin for MicroPython 1.24 with MDNS disabled and the mdns python module included in each release for easy usage. Other Micropython versions are also supported. Other boards are supported and you are welcome to add additional Dockerfiles in a PR which build the module compatible for other boards. 32 | All versions can also be built when having docker locally installed by running in the console the build command: 33 | 34 | ```bash 35 | make build 36 | ``` 37 | 38 | Individually it is also possible to build the desired version via: 39 | 40 | ```bash 41 | make compile-micropython-1-24 42 | ``` 43 | 44 | ### ESP32 45 | 46 | Refer to the [`config`](https://github.com/cbrand/micropython-mdns/tree/main/config/boards) directory to see the configuration files when baking this into your own MicroPython ESP32 build. 47 | 48 | Alternatively you can also build it with docker by calling: 49 | 50 | ```bash 51 | docker build -t micropython -f Dockerfile.micropython.1.24.esp32 52 | docker run -v ./:/tmp/mdns-build -t micropython 53 | ``` 54 | 55 | This will put the ESP32 firmware in the current working directory. 56 | 57 | ### Raspberry Pi Pico 58 | 59 | For Raspberry Pi Pico support see the corresponding Dockerfile on how to compile it in linux [`Dockerfile.micropython.1.24.rp2`](https://github.com/cbrand/micropython-mdns/tree/main/Dockerfile.micropython.1.24.rp2). 60 | 61 | You can also run a build with docker by calling: 62 | 63 | ```bash 64 | docker build -t micropython -f Dockerfile.micropython.1.24.rp2 65 | docker run -v ./:/tmp/mdns-build -t micropython 66 | ``` 67 | 68 | This will put the RP2 firmware in the current working directory. 69 | 70 | ### Other 71 | 72 | Other MicroPython implementations might not require any changes inside of the firmware. 73 | 74 | ## Usage 75 | 76 | The library requires [`uasyncio`](https://docs.micropython.org/en/latest/library/uasyncio.html) to function. All handling is done asynchronously. 77 | 78 | Examples on how to utilize the libraries can be found in the [`examples`](https://github.com/cbrand/micropython-mdns/tree/main/examples) folder. 79 | 80 | ## Reference 81 | 82 | A basic API reference for the public API is inside of the [REFERENCE.md](https://github.com/cbrand/micropython-mdns/blob/main/REFERENCE.md). 83 | 84 | ## Caveats 85 | 86 | - Depending on your MicroPython implementation, you must disable MDNS in the firmware. 87 | - For ESP32 use the Dockerfile for the specific Micropython version in the root directory. 88 | - For RPI Pico Micropython beginning with version 1.32 is supported since version 1.5.0 where you can see how to do it in the Dockerfile in the root directory. 89 | - Currently no support for IPv6 is implemented. 90 | - Depending how chatty the network is, service responders and discovery might require a lot of memory. If the memory is filled by the buffer of the underlying socket, [the socket is closed and reopened](https://github.com/cbrand/micropython-mdns/blob/d3dd54f809629ca41c525f5dec86963a6d75e903/src/mdns_client/client.py#L100) which looses data. It, however, seems to work fine enough in tests on an ESP32 without external memory. Depending on the project size, a module with external RAM might be advisable. 91 | 92 | ## License 93 | 94 | The library is published under the [MIT](https://github.com/cbrand/micropython-mdns/blob/main/LICENSE) license. 95 | -------------------------------------------------------------------------------- /REFERENCE.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | The API consists of various classes. The main logic is automatically 4 | spinned up inside of a [`uasyncio` task](https://docs.micropython.org/en/latest/library/uasyncio.html#uasyncio.create_task) 5 | which is started and stopped when required. 6 | 7 | The document doesn't outline all classes and functionality in the library. 8 | Instead, it outlines the API which can be seen as **stable** and intended for public use. 9 | 10 | ## `mdns_client.Client` 11 | 12 | [![Link to the mdns_client.Client code](https://img.shields.io/badge/mdns__client-Client-orange)](src/mdns_client/client.py#30) 13 | 14 | The client includes the basic logic for requesting and sending MDNS packages. It has a pluggable system to react on MDNS Record changes on the network. 15 | 16 | All functionality in this library is based on this client. 17 | 18 | ```python 19 | import uasyncio 20 | import network 21 | 22 | from mdns_client import Client 23 | 24 | loop = uasyncio.get_event_loop() 25 | wlan = network.WLAN(network.STA_IF) 26 | client = Client(wlan.ifconfig()[0]) 27 | 28 | print(loop.run_until_complete(client.getaddrinfo("the-other-device.local", 80))) 29 | ``` 30 | 31 | **Reference** 32 | 33 | ```python 34 | Client.__init__(local_addr: str, debug: bool = False) 35 | ``` 36 | 37 | Initializes the client. It requires the local ip address for subscribing 38 | to multicast messages. 39 | 40 | If debug is enabled, the client will issue debug message via the 41 | print() statement. 42 | 43 | ```python 44 | Client.add_callback( 45 | callback: "Callable[[DNSResponse], Awaitable[None]]", 46 | remove_if: "Optional[Callable[[DNSResponse], Awaitable[bool]]]" = None, 47 | timeout: "Optional[int]" = None 48 | ) -> Callback 49 | ``` 50 | 51 | Registers a callback function which gets executed every time 52 | an MDNS message has been received and deserialized into a [DNSResponse](#DNSResponse) object. 53 | 54 | Optionally, a function can be passed in which gets executed to verify if 55 | the callback should be deleted. 56 | 57 | If a timeout is given, the callback will be removed after the specified 58 | time has passed. 59 | 60 | Returns the registered callback object which has an `id` which can be used 61 | to manually deregister the response from the client. 62 | 63 | ```python 64 | Client.remove_id(callback_id: int) -> bool 65 | ``` 66 | 67 | Removes a registered callback with the given id. 68 | 69 | Returns True if the deletion was done and false, if no callback with the 70 | passed id has been found. 71 | 72 | ```python 73 | async Client.send_question(*questions: DNSQuestion) -> None 74 | async Client.send_response(response: DNSResponse) -> None 75 | ``` 76 | 77 | Sends a DNS resolution question or response into the local network. This is mainly 78 | used by other parts of the library but is considered as public, if 79 | an extension of the library is required. 80 | 81 | ```python 82 | async Client.getaddrinfo( 83 | host: "Union[str, bytes, bytearray]", 84 | port: "Union[str, int, None]", 85 | family: int = 0, 86 | type: int = 0, 87 | proto: int = 0, 88 | flags: int = 0, 89 | ) -> "List[Tuple[int, int, int, str, Tuple[str, int]]]" 90 | ``` 91 | 92 | This implements the same interface as exists in the standard library 93 | on [socket objects for DNS resolution](https://docs.micropython.org/en/latest/library/usocket.html#usocket.getaddrinfo). 94 | The function supports both resolutions of local MDNS and normal 95 | DNS queries. Addresses which are prefixed with `.local` are resolved 96 | via MDNS, all others get sent to the configured `DNS` server via 97 | the [`socket.getaddrinfo`](https://docs.micropython.org/en/latest/library/usocket.html#usocket.getaddrinfo) function. 98 | 99 | ```python 100 | host_name = str 101 | ip_v4_address = str 102 | async Client.mdns_getaddr(self, host: str) -> Tuple[host_name, ip_v4_address] 103 | ``` 104 | 105 | Resolves a pure MDNS A record requests. It returnes 106 | the record name as the first entry and the ipv4 address as the second 107 | tuple entry. 108 | 109 | ## `mdns_client.responder.Responder` 110 | 111 | [![Link to the mdns_client.responder.Responder code](https://img.shields.io/badge/mdns__client.responder-Responder-orange)](src/mdns_client/responer.py#29) 112 | 113 | This class is used for supporting service annoucements for own services 114 | supported by the application running on this Microcontroller. 115 | 116 | ```python 117 | import uasyncio 118 | import network 119 | 120 | from mdns_client import Client 121 | from mdns_client.responder import Responder, generate_random_postfix 122 | 123 | loop = uasyncio.get_event_loop() 124 | wlan = network.WLAN(network.STA_IF) 125 | local_ip = wlan.ifconfig()[0] 126 | client = Client(local_ip) 127 | postfix = generate_random_postfix() 128 | responder = Responder(client, own_ip=local_ip, host=lambda: "my-device-{}".format(postfix)) 129 | responder.advertise("_my_awesome_protocol", "_tcp", port=12345) 130 | ``` 131 | 132 | **Reference** 133 | 134 | ```python 135 | Responder.__init__( 136 | client: Client, 137 | own_ip: "Union[None, str, Callable[[], str]]", 138 | host: "Union[None, str, Callable[[], str]]" = None, 139 | debug: bool = False, 140 | ) -> None 141 | ``` 142 | 143 | The responder is initialized with a client object. It requires 144 | a callback or fixed value identifying the local ip. 145 | 146 | Additionally, a hostname can be passed into the constructor which identifies the name how the responder advertises itself. If none is passed, 147 | `micropython-{six digit hexadecimal value}` is generated. 148 | 149 | The `debug` flag can be set to `True` if debug messages should be printed 150 | via the `print()` command. 151 | 152 | ```python 153 | Responder.advertise( 154 | protocol: str, 155 | service: str, 156 | port: int, 157 | data: "Optional[Dict[str, Union[List[str], str]]]" = None, 158 | service_host_name: "Optional[str]" = None 159 | ) -> None 160 | ``` 161 | 162 | Advertises the specified protocol/service to be available on the given port. 163 | 164 | Optionally, data can be passed, which is published as a `TXT` record to provide further instructions to third parties on how to handle the service. . If you want to update the `TXT` data, you can call the function again, which will overwrite the previous setting if you pass the same `protocol`/`service` combination to it. 165 | 166 | In very special cases, you might want to set your own service hostname different from the hostname in the A record of the controller. This is where `service_host_name` can be used to generate a dedicated hostname for the advertised service, such as `myservicehostname._myawesomeservice._tcp.local`. 167 | 168 | ```python 169 | Responder.withdraw(protocol: str, service: str) -> None 170 | ``` 171 | 172 | Removes the service annoucement support of the passed `protocol`/`service` tuple. 173 | 174 | ## `mdns_client.service_discovery.ServiceDiscovery` 175 | 176 | [![Link to the mdns_client.service_discovery.ServiceDiscovery code](https://img.shields.io/badge/mdns__client.service__discovery-ServiceDiscovery-orange)](src/mdns_client/service_discovery/discovery.py#24) 177 | [![Link to the mdns_client.service_discovery.txt_discovery.TxtServiceDiscovery code](https://img.shields.io/badge/mdns__client.service__discovery.txt__discovery-TXTServiceDiscovery-orange)](src/mdns_client/service_discovery/txt_discovery.py#15) 178 | 179 | Also [`mdns_client.service_discovery.txt_discovery.TXTServiceDiscovery`](src/mdns_client/service_discovery/txt_discovery.py#15) 180 | provides the same interface. 181 | 182 | The `ServiceDiscovery` class and the `TXTServiceDiscovery` class provide possibilities for service discovery on the local network. They implement the same public interface. If the `TXTServiceDiscovery` is used, it will additional populate all records with any metadata which is passed via TXT records for the services. 183 | 184 | ```python 185 | import uasyncio 186 | import network 187 | 188 | from mdns_client import Client 189 | from mdns_client.service_discovery import ServiceDiscovery 190 | 191 | loop = uasyncio.get_event_loop() 192 | wlan = network.WLAN(network.STA_IF) 193 | local_ip = wlan.ifconfig()[0] 194 | client = Client(local_ip) 195 | discovery = ServiceDiscovery(client) 196 | loop.run_until_complete(discovery.query_once("_googlecast", "_tcp", timeout=1.0)) 197 | ``` 198 | 199 | **Reference** 200 | 201 | ```python 202 | ServiceDiscovery.__init__(client: Client, debug: bool = False) 203 | ``` 204 | 205 | Initializes the service discovery handler. Requires the `Client` object. 206 | 207 | if `debug` is set to `True` messages helping debugging the component will be sent via the `print` statement. 208 | 209 | ```python 210 | ServiceDiscovery.add_service_monitor(service_monitor: "ServiceMonitor") -> None 211 | ``` 212 | 213 | Adds a specific service monitor to the discovery handler. Each time a service is updated, added or removed and marked as relevant it will be sent to the serivce monitor till it has been unregistered via `remove_service_monitor`. 214 | 215 | The `ServiceMonitor` must implement these functions: 216 | 217 | ```python 218 | class ServiceMonitor: 219 | def service_added(self, service: ServiceResponse) -> None: 220 | pass 221 | def service_updated(self, service: ServiceResponse) -> None: 222 | pass 223 | def service_removed(self, service: ServiceResponse) -> None: 224 | pass 225 | ``` 226 | 227 | ```python 228 | ServiceDiscovery.remove_service_monitor(service_monitor: "ServiceMonitor") -> None 229 | ``` 230 | 231 | Removes the specified service monitor from the discovery logic. If the service monitor hasn't been registered before, raises a `KeyError`. 232 | 233 | ```python 234 | async ServiceDiscovery.query(protocol: str, service: str) -> None 235 | ``` 236 | 237 | Marks the specified `protocol`/`service` as to be resolved. Once it is added to the query, the records are being taken in. Also issues a query for 238 | resolving the services which are currently available on the connected network. 239 | 240 | ```python 241 | async ServiceDiscovery.query_once(protocol: str, service: str, timeout: float = None) -> "Iterable[ServiceResponse]" 242 | ``` 243 | 244 | Asks the service discovery to resolve the `protocol`/`service` combination once. After the passed `timeout` (or a default one) has passed return all services which have been identified in between. 245 | 246 | If the `protocol`/`service` combination hasn't already been enqueued before (via a `query` call), it will afterwards remove the query request. Because of this behavior, the call is on the long run more memory efficient than constantly scanning the network for service records via the `query` function. 247 | 248 | ```python 249 | ServiceDiscovery.current(protocol: str, service: str) -> "Iterable[ServiceResponse]" 250 | ``` 251 | 252 | Returns the currently known services which provide the passed `protocol`/`service` on the network. It doesn't itself do any network calls and checks the state from the local cache. Thus, it should be used in combination with the `query` function. 253 | 254 | ## Structs 255 | 256 | ### `mdns_client.struct.DNSResponse` 257 | 258 | [![Link to the mdns_client.struct.DNSResponse code](https://img.shields.io/badge/mdns__client.structs-DNSResponse-orange)](src/mdns_client/structs.py#78) 259 | 260 | The DNSResponse is a namedtuple representing a received or to be sent MDNS-Response. 261 | 262 | ```python 263 | class DNSResponse: 264 | transaction_id: int 265 | message_type: int 266 | questions: "List[DNSQuestion]" 267 | answers: "List[DNSRecord]" 268 | authorities: "List[DNSRecord]" 269 | additional: "List[DNSRecord]" 270 | is_response: bool 271 | is_request: bool 272 | records: "Iterable[DNSRecord]" 273 | ``` 274 | 275 | **Reference** 276 | 277 | ```python 278 | DNSResponse.to_bytes() -> bytes 279 | ``` 280 | 281 | Returns a representation of the DNSResponse in bytes as they are sent 282 | inside of a UDP package. 283 | 284 | ### `mdns_client.struct.DNSRecord` 285 | 286 | [![Link to the mdns_client.struct.DNSRecord code](https://img.shields.io/badge/mdns__client.structs-DNSRecord-orange)](src/mdns_client/structs.py#47) 287 | 288 | The DNSRecord namedtuple represents an individual record which has been 289 | received or will be sent via a DNSResponse. 290 | 291 | ```python 292 | class DNSRecord: 293 | name: str 294 | record_type: int 295 | query_class: int 296 | time_to_live: int 297 | rdata: bytes 298 | invalid_at: int # The time (in machine.ticks_ms) when the record is invalid 299 | ``` 300 | 301 | **Reference** 302 | 303 | ```python 304 | DNSRecord.to_bytes() -> bytes 305 | ``` 306 | 307 | Returns a representation of the `DNSRecord` in bytes as they are sent 308 | inside of a UDP package within a `DNSResponse`. 309 | 310 | ### `mdns_client.struct.DNSQuestion` 311 | 312 | [![Link to the mdns_client.struct.DNSQuestion code](https://img.shields.io/badge/mdns__client.structs-DNSQuestion-orange)](src/mdns_client/structs.py#17) 313 | 314 | The DNSQuestion represents a request being sent via MDNS to resolve a 315 | specific domain record of a specified type. 316 | 317 | ```python 318 | class DNSQuestion: 319 | query: str 320 | type: int 321 | query_class: int 322 | ``` 323 | 324 | The `type` can be resolved via the [`constants.py`](src/mdns_client/constants.py#L21-L31) file. 325 | Also the `query_class` types are [there](src/mdns_client/constants.py#L16-L19). However, currently only the 326 | `CLASS_IN` is used. 327 | 328 | **Reference** 329 | 330 | ```python 331 | DNSQuestion.to_bytes() -> bytes 332 | ``` 333 | 334 | Returns a representation of the `DNSQuestion` in bytes as they are sent 335 | inside of a UDP package within a `DNSResponse`. 336 | 337 | ### `mdns_client.service_discovery.ServiceResponse` 338 | 339 | [![Link to the mdns_client.struct.DNSQuestion code](https://img.shields.io/badge/mdns__client.service__discovery.service__response-ServiceResponse-orange)](src/mdns_client/service_discovery/service_response.py#7) 340 | 341 | The `ServiceResponse` includes information of a service which is available on the local network and has been discovered via the `ServiceDiscovery` class or one of its subclasses. 342 | 343 | ```python 344 | class ServiceResponse: 345 | name: str 346 | priority: int 347 | weight: int 348 | port: int 349 | ips: Set[str] 350 | txt_records: Optional[Dict[str, List[str]]] 351 | 352 | invalid_at: Optional[int] 353 | # time.ticks_ms when the service is no longer valid 354 | # according to the passed time to live 355 | 356 | ttl: Optional[int] 357 | # The time the service response can be considered as 358 | # valid when it was received from the MDNS responder. 359 | ``` 360 | 361 | **Reference** 362 | 363 | ```python 364 | ServiceResponse.expired_at(timing: int) -> bool 365 | ``` 366 | 367 | Returns if the service response is expired at the specified time. 368 | The timing should be the time according to [`time.ticks_ms()`](https://docs.micropython.org/en/latest/library/utime.html#utime.ticks_ms). 369 | -------------------------------------------------------------------------------- /build-and-copy-firmware.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export MICROPYTHON_VERSION="${MICROPYTHON_VERSION:-1.24}" 4 | export MICROPYTHON_PORT="${MICROPYTHON_PORT:-esp32}" 5 | 6 | docker build -t esp32-mdns-client:micropython.${MICROPYTHON_VERSION}.${MICROPYTHON_PORT} -f Dockerfile.micropython.${MICROPYTHON_VERSION}.${MICROPYTHON_PORT} . 7 | docker run --rm -v "$(pwd):/tmp/mdns-build" -i -t esp32-mdns-client:micropython.${MICROPYTHON_VERSION}.${MICROPYTHON_PORT} 8 | -------------------------------------------------------------------------------- /config/boards-after-micropython-1-20/MDNS/manifest.py: -------------------------------------------------------------------------------- 1 | freeze("$(PORT_DIR)/modules") 2 | include("$(MPY_DIR)/extmod/asyncio") 3 | 4 | # Useful networking-related packages. 5 | require("bundle-networking") 6 | 7 | # Require some micropython-lib modules. 8 | require("aioespnow") 9 | require("dht") 10 | require("ds18x20") 11 | require("neopixel") 12 | require("onewire") 13 | require("umqtt.robust") 14 | require("umqtt.simple") 15 | require("upysh") 16 | -------------------------------------------------------------------------------- /config/boards-after-micropython-1-20/MDNS/mpconfigboard.cmake: -------------------------------------------------------------------------------- 1 | set(SDKCONFIG_DEFAULTS 2 | boards/sdkconfig.base 3 | boards/sdkconfig.ble 4 | boards/sdkconfig.mdns 5 | ) 6 | 7 | set(MICROPY_FROZEN_MANIFEST ${MICROPY_BOARD_DIR}/manifest.py) 8 | -------------------------------------------------------------------------------- /config/boards-after-micropython-1-20/MDNS/mpconfigboard.h: -------------------------------------------------------------------------------- 1 | #define MICROPY_HW_BOARD_NAME "ESP32 module (mdns)" 2 | #define MICROPY_HW_MCU_NAME "ESP32" 3 | #define MICROPY_HW_ENABLE_MDNS_RESPONDER 0 4 | #define MICROPY_HW_ENABLE_MDNS_QUERIES 0 5 | -------------------------------------------------------------------------------- /config/boards-after-micropython-1-20/MDNS/mpconfigboard.mk: -------------------------------------------------------------------------------- 1 | SDKCONFIG += boards/sdkconfig.base 2 | SDKCONFIG += boards/sdkconfig.mdns 3 | -------------------------------------------------------------------------------- /config/boards-after-micropython-1-20/sdkconfig.mdns: -------------------------------------------------------------------------------- 1 | CONFIG_ESP_TASK_WDT_PANIC=y 2 | CONFIG_ESP_SYSTEM_PANIC=ESP_SYSTEM_PANIC_PRINT_REBOOT 3 | -------------------------------------------------------------------------------- /config/boards/MDNS/mpconfigboard.cmake: -------------------------------------------------------------------------------- 1 | set(SDKCONFIG_DEFAULTS 2 | boards/sdkconfig.base 3 | boards/sdkconfig.ble 4 | boards/sdkconfig.mdns 5 | ) 6 | 7 | set(MICROPY_FROZEN_MANIFEST ${MICROPY_PORT_DIR}/boards/manifest.py) 8 | -------------------------------------------------------------------------------- /config/boards/MDNS/mpconfigboard.h: -------------------------------------------------------------------------------- 1 | #define MICROPY_HW_BOARD_NAME "ESP32 module (mdns)" 2 | #define MICROPY_HW_MCU_NAME "ESP32" 3 | #define MICROPY_HW_ENABLE_MDNS_RESPONDER 0 4 | #define MICROPY_HW_ENABLE_MDNS_QUERIES 0 5 | -------------------------------------------------------------------------------- /config/boards/MDNS/mpconfigboard.mk: -------------------------------------------------------------------------------- 1 | SDKCONFIG += boards/sdkconfig.base 2 | SDKCONFIG += boards/sdkconfig.mdns 3 | -------------------------------------------------------------------------------- /config/boards/sdkconfig.mdns: -------------------------------------------------------------------------------- 1 | CONFIG_ESP_TASK_WDT_PANIC=y 2 | CONFIG_ESP_SYSTEM_PANIC=ESP_SYSTEM_PANIC_PRINT_REBOOT 3 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Micropython MDNS # 2 | 3 | ## Examples ## 4 | 5 | For easier utilization a couple of examples are listed in this directory. 6 | 7 | They all have been tested on an ESP32 utilizing MicroPython compiled by running `make compile` in the root directory. 8 | 9 | You require docker to build the Firmware. 10 | 11 | The default firmware can not be used, as it has MDNS enabled and blocks the MDNS port. 12 | 13 | 14 | ### Resolve an A record ### 15 | 16 | 17 | DNS lookup via the getaddrinfo() call. It supports querying both MDNS and regular DNS queries. 18 | 19 | ### Continuous MDNS service discovery ### 20 | 21 | 22 | 23 | MDNS Service discovery. Supports continuous notifications of updates, additions and removals of services in the network. 24 | 25 | 26 | ### One time MDNS service discovery ### 27 | 28 | 29 | 30 | MDNS Service discovery one time requests the current state with a configureable timeout. 31 | 32 | ### MDNS Service Responder / Annoucement ### 33 | 34 | 35 | 36 | MDNS service record annoucement for publishing own services to the local network with Metadata being published via a TXT record. 37 | -------------------------------------------------------------------------------- /examples/request_a_record.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example script queries a local address and google. The google request is 3 | delegated to be resolved as getaddrinfo call of socket.socket, the local address 4 | utilizes mdns. 5 | """ 6 | 7 | import network 8 | import uasyncio 9 | 10 | from mdns_client import Client 11 | 12 | wlan = network.WLAN(network.STA_IF) 13 | wlan.active(True) 14 | wlan.connect("", "") 15 | while not wlan.isconnected(): 16 | import time 17 | 18 | time.sleep(1.0) 19 | 20 | own_ip_address = wlan.ifconfig()[0] 21 | 22 | loop = uasyncio.get_event_loop() 23 | client = Client(own_ip_address) 24 | 25 | 26 | async def query_mdns_and_dns_address(): 27 | try: 28 | print(await client.getaddrinfo("google.de", 80)) 29 | except OSError: 30 | print("DNS address not found") 31 | try: 32 | print(await client.getaddrinfo("74d94d3d-8325-69ee-f5cf-c08d901e3bd3.local", 80)) 33 | except OSError: 34 | print("MDNS address not found") 35 | 36 | 37 | loop.run_until_complete(query_mdns_and_dns_address()) 38 | -------------------------------------------------------------------------------- /examples/service_discovery_constant.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example runs a constant service discovery for google chromecast services. 3 | It prints them out if they are added, changed or removed. 4 | 5 | As this method stores local state, like service records for refreshing the state 6 | it requires more memory than the one time discovery. However, depending on your use 7 | case it might be better to use this instead of the one time query. 8 | """ 9 | 10 | import network 11 | import uasyncio 12 | 13 | from mdns_client import Client 14 | from mdns_client.service_discovery import ServiceResponse 15 | from mdns_client.service_discovery.txt_discovery import TXTServiceDiscovery 16 | 17 | wlan = network.WLAN(network.STA_IF) 18 | wlan.active(True) 19 | wlan.connect("", "") 20 | while not wlan.isconnected(): 21 | import time 22 | 23 | time.sleep(1.0) 24 | 25 | own_ip_address = wlan.ifconfig()[0] 26 | 27 | loop = uasyncio.get_event_loop() 28 | client = Client(own_ip_address) 29 | discovery = TXTServiceDiscovery(client) 30 | 31 | 32 | class ServiceMonitor: 33 | def service_added(self, service: ServiceResponse) -> None: 34 | print("Service added: {}".format(service)) 35 | 36 | def service_updated(self, service: ServiceResponse) -> None: 37 | print("Service updated: {}".format(service)) 38 | 39 | def service_removed(self, service: ServiceResponse) -> None: 40 | print("Service removed: {}".format(service)) 41 | 42 | 43 | async def discover(): 44 | discovery.add_service_monitor(ServiceMonitor()) 45 | await discovery.query("_googlecast", "_tcp") 46 | 47 | await uasyncio.sleep(20) 48 | 49 | 50 | loop.run_until_complete(discover()) 51 | print(discovery.current("_googlecast", "_tcp")) 52 | -------------------------------------------------------------------------------- /examples/service_discovery_once.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows a one time discovery of available google cast services in the network. 3 | This is probably the most efficient way to discover services, as it doesn't store any additional 4 | local state after the query is done and should be used if memory is an issue in your application. 5 | """ 6 | 7 | import network 8 | import uasyncio 9 | 10 | from mdns_client import Client 11 | from mdns_client.service_discovery.txt_discovery import TXTServiceDiscovery 12 | 13 | wlan = network.WLAN(network.STA_IF) 14 | wlan.active(True) 15 | wlan.connect("", "") 16 | while not wlan.isconnected(): 17 | import time 18 | 19 | time.sleep(1.0) 20 | 21 | own_ip_address = wlan.ifconfig()[0] 22 | 23 | loop = uasyncio.get_event_loop() 24 | client = Client(own_ip_address) 25 | discovery = TXTServiceDiscovery(client) 26 | 27 | 28 | async def discover_once(): 29 | print(await discovery.query_once("_googlecast", "_tcp", timeout=1.0)) 30 | 31 | 32 | loop.run_until_complete(discover_once()) 33 | -------------------------------------------------------------------------------- /examples/service_responder.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example implements the service responder. Allowing to publish services with TXT record information 3 | for the own local ip. For this, it requires a host name (if none is given an 4 | micropython-{6 hexadecimal digits}) is generated. To randomize the name you can utilize 5 | responder.generate_random_postfix(). 6 | 7 | It allows for advanced MDNS discovery for your MicroPython driven project. 8 | """ 9 | 10 | import network 11 | import uasyncio 12 | 13 | from mdns_client import Client 14 | from mdns_client.responder import Responder 15 | 16 | wlan = network.WLAN(network.STA_IF) 17 | wlan.active(True) 18 | wlan.connect("", "") 19 | while not wlan.isconnected(): 20 | import time 21 | 22 | time.sleep(1.0) 23 | 24 | own_ip_address = wlan.ifconfig()[0] 25 | 26 | loop = uasyncio.get_event_loop() 27 | client = Client(own_ip_address) 28 | responder = Responder( 29 | client, 30 | own_ip=lambda: own_ip_address, 31 | host=lambda: "my-awesome-microcontroller-{}".format(responder.generate_random_postfix()), 32 | ) 33 | 34 | 35 | def announce_service(): 36 | responder.advertise("_myawesomeservice", "_tcp", port=12345, data={"some": "metadata", "for": ["my", "service"]}) 37 | # If you want to set a dedicated service host name 38 | responder.advertise( 39 | "_myawesomeservice", 40 | "_tcp", 41 | port=12345, 42 | data={"some": "metadata", "for": ["my", "service"]}, 43 | service_host_name="specialcontrollerservicename", 44 | ) 45 | 46 | 47 | announce_service() 48 | loop.run_forever() 49 | -------------------------------------------------------------------------------- /generate-package-json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import json 4 | import re 5 | from os import walk 6 | from pathlib import Path 7 | from typing import TypedDict 8 | 9 | 10 | VERSION_FINDER = re.compile( 11 | r'__version__\s*=\s*"(?P[0-9]+\.[0-9]+\.[0-9]+)"', re.MULTILINE | re.UNICODE | re.IGNORECASE 12 | ) 13 | 14 | 15 | class MipDict(TypedDict): 16 | version: str 17 | deps: list[str] 18 | urls: list[list[str]] 19 | 20 | 21 | def get_root_path() -> Path: 22 | return Path(__file__, "..", "src").resolve() 23 | 24 | 25 | def get_package_json_path() -> Path: 26 | return (get_root_path() / ".." / "package.json").resolve() 27 | 28 | 29 | def get_setup_py_path() -> Path: 30 | return get_root_path() / "setup.py" 31 | 32 | 33 | def generate_urls_entries() -> list[list[str]]: 34 | urls = [] 35 | root_path = get_root_path() 36 | for root, _, files in walk(root_path / "mdns_client"): 37 | for file in files: 38 | file_path = Path(root, file) 39 | if file_path.suffix == ".py": 40 | relpath = str(file_path.relative_to(root_path)).replace("\\", "/") 41 | urls.append([relpath, f"github:cbrand/micropython-mdns/src/{relpath}"]) 42 | return urls 43 | 44 | 45 | def get_current_version() -> str: 46 | for item in get_setup_py_path().read_text().split("\n"): 47 | matcher = VERSION_FINDER.match(item) 48 | if matcher is not None: 49 | return matcher.group("version") 50 | return "dev" 51 | 52 | 53 | def generate_mip_json() -> MipDict: 54 | return MipDict( 55 | version=get_current_version(), 56 | deps=[], 57 | urls=generate_urls_entries(), 58 | ) 59 | 60 | 61 | if __name__ == "__main__": 62 | package_json_path = get_package_json_path() 63 | package_json_path.write_text(json.dumps(generate_mip_json())) 64 | -------------------------------------------------------------------------------- /images/service-discovery.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbrand/micropython-mdns/0fb235f0ba5a06ecba82eac305eb0029f46c0fb7/images/service-discovery.gif -------------------------------------------------------------------------------- /images/service-discovery.rec: -------------------------------------------------------------------------------- 1 | {"version": 2, "width": 226, "height": 49, "timestamp": 1609886641, "env": {"SHELL": "/usr/bin/zsh", "TERM": "xterm-256color"}} 2 | [0.582018, "o", ">>> "] 3 | [1.0, "o", "discovery.query('_googlecast', '_tcp')\r\n"] 4 | [1.1, "o", ">>> "] 5 | [1.5, "o", "loop.run_forever()\r\n"] 6 | [1.959639, "o", "Service added: \r\n"] 9 | [2.007527, "o", "Service added: \r\n"] 11 | [2.05249, "o", "Service added: \r\n"] 13 | [2.09476, "o", "Service added: \r\n"] 15 | [3.885758, "o", "Service added: \r\n"] 17 | [3.935518, "o", "Service added: \r\n"] 19 | [3.981508, "o", "Service added: \r\n"] 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | {"version": "1.6.0", "deps": [], "urls": [["mdns_client/parser.py", "github:cbrand/micropython-mdns/src/mdns_client/parser.py"], ["mdns_client/util.py", "github:cbrand/micropython-mdns/src/mdns_client/util.py"], ["mdns_client/__init__.py", "github:cbrand/micropython-mdns/src/mdns_client/__init__.py"], ["mdns_client/structs.py", "github:cbrand/micropython-mdns/src/mdns_client/structs.py"], ["mdns_client/responder.py", "github:cbrand/micropython-mdns/src/mdns_client/responder.py"], ["mdns_client/constants.py", "github:cbrand/micropython-mdns/src/mdns_client/constants.py"], ["mdns_client/client.py", "github:cbrand/micropython-mdns/src/mdns_client/client.py"], ["mdns_client/service_discovery/txt_discovery.py", "github:cbrand/micropython-mdns/src/mdns_client/service_discovery/txt_discovery.py"], ["mdns_client/service_discovery/service_monitor.py", "github:cbrand/micropython-mdns/src/mdns_client/service_discovery/service_monitor.py"], ["mdns_client/service_discovery/discovery.py", "github:cbrand/micropython-mdns/src/mdns_client/service_discovery/discovery.py"], ["mdns_client/service_discovery/__init__.py", "github:cbrand/micropython-mdns/src/mdns_client/service_discovery/__init__.py"], ["mdns_client/service_discovery/service_response.py", "github:cbrand/micropython-mdns/src/mdns_client/service_discovery/service_response.py"]]} -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ["py38"] 4 | 5 | [tool.isort] 6 | line_length=120 7 | multi_line_output=3 8 | include_trailing_comma="True" 9 | use_parentheses="True" 10 | force_grid_wrap=0 11 | 12 | [tool.unimport] 13 | exclude='(__init__.py)|venv|env' 14 | remove="True" 15 | 16 | [tool.semantic_release] 17 | version_variables = ['src/setup.py:__version__'] 18 | tag_format = '{version}' 19 | commit_parser = "angular" 20 | changelog_file = "CHANGELOG.md" 21 | branch = "main" 22 | build_command = "make compile build upload" 23 | dist_path = "src/dist/" 24 | upload_to_repository = false 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | esptool 2 | adafruit-ampy 3 | twine 4 | python-semantic-release 5 | wheel>=0.38.0 # not directly required, pinned by Snyk to avoid a vulnerability 6 | certifi>=2023.7.22 # not directly required, pinned by Snyk to avoid a vulnerability 7 | cryptography>=41.0.4 # not directly required, pinned by Snyk to avoid a vulnerability 8 | pygments>=2.15.0 # not directly required, pinned by Snyk to avoid a vulnerability 9 | requests>=2.32.0 # not directly required, pinned by Snyk to avoid a vulnerability 10 | setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability 11 | urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability 12 | zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability 13 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /src/mdns_client/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | 3 | from mdns_client.client import Client 4 | -------------------------------------------------------------------------------- /src/mdns_client/client.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import socket 3 | import time 4 | from collections import namedtuple 5 | from select import select 6 | 7 | import uasyncio 8 | 9 | from mdns_client.constants import CLASS_IN, LOCAL_MDNS_SUFFIX, MAX_PACKET_SIZE, MDNS_ADDR, MDNS_PORT, TYPE_A 10 | from mdns_client.parser import parse_packet 11 | from mdns_client.structs import DNSQuestion, DNSQuestionWrapper, DNSRecord, DNSResponse 12 | from mdns_client.util import a_record_rdata_to_string, dotted_ip_to_bytes, set_after_timeout 13 | 14 | 15 | class Callback(namedtuple("Callback", ["id", "callback", "remove_if", "timeout", "created_ticks"])): 16 | id: int 17 | callback: "Callable[[DNSResponse], Awaitable[None]]" 18 | remove_if: "Optional[Callable[[DNSResponse], Awaitable[bool]]]" 19 | timeout: "Optional[float]" 20 | created_ticks: int 21 | 22 | @property 23 | def timedout(self) -> bool: 24 | if self.timeout is None: 25 | return False 26 | 27 | return self.created_ticks + int(self.timeout * 1000) < time.ticks_ms() 28 | 29 | 30 | class Client: 31 | def __init__(self, local_addr: str, debug: bool = False): 32 | self.socket: "Optional[socket.socket]" = None 33 | self.local_addr = local_addr 34 | self.debug = debug 35 | self.print_packets = debug 36 | self.stopped = True 37 | self.callbacks: "Dict[int, Callback]" = {} 38 | self.callback_fd_count: int = 0 39 | self.mdns_timeout = 2.0 40 | 41 | def add_callback( 42 | self, 43 | callback: "Callable[[DNSResponse], Awaitable[None]]", 44 | remove_if: "Optional[Callable[[DNSResponse], Awaitable[bool]]]" = None, 45 | timeout: "Optional[int]" = None, 46 | ) -> Callback: 47 | callback_config = Callback( 48 | id=self.callback_fd_count, 49 | callback=callback, 50 | remove_if=remove_if, 51 | timeout=timeout, 52 | created_ticks=time.ticks_ms(), 53 | ) 54 | self.callback_fd_count += 1 55 | self.dprint("Adding callback with id {}".format(callback_config.id)) 56 | self.callbacks[callback_config.id] = callback_config 57 | if self.stopped: 58 | self.dprint("Added consumer on stopped mdns client. Starting it now.") 59 | self.stopped = False 60 | loop = uasyncio.get_event_loop() 61 | loop.create_task(self.start()) 62 | return callback_config 63 | 64 | def _make_socket(self) -> socket.socket: 65 | self.dprint("Creating socket for address %s" % (self.local_addr)) 66 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 67 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 68 | member_info = dotted_ip_to_bytes(MDNS_ADDR) + dotted_ip_to_bytes(self.local_addr) 69 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, member_info) 70 | sock.setblocking(False) 71 | self.dprint("Socket creation finished") 72 | return sock 73 | 74 | async def start(self) -> None: 75 | self.stopped = False 76 | self._init_socket() 77 | await self.consume() 78 | 79 | def _init_socket(self) -> None: 80 | self._close_socket() 81 | self.socket = self._make_socket() 82 | self.socket.bind((MDNS_ADDR, MDNS_PORT)) 83 | self.dprint("Bind finished ready to use MDNS query data") 84 | 85 | def stop(self) -> None: 86 | self.stopped = True 87 | self._close_socket() 88 | 89 | def _close_socket(self) -> None: 90 | if self.socket is not None: 91 | self.socket.close() 92 | self.socket = None 93 | 94 | async def consume(self) -> None: 95 | while not self.stopped: 96 | await self.process_waiting_data() 97 | await uasyncio.sleep_ms(100) 98 | 99 | async def process_waiting_data(self) -> None: 100 | while not self.stopped: 101 | readers, _, _ = select([self.socket], [], [], 0) 102 | if not readers: 103 | break 104 | 105 | try: 106 | buffer, addr = self.socket.recvfrom(MAX_PACKET_SIZE) 107 | except MemoryError: 108 | # This seems to happen here without SPIRAM sometimes. 109 | self.dprint( 110 | "Issue processing network data due to insufficient memory. " 111 | "Rebooting the socket to free up cache buffer." 112 | ) 113 | self._init_socket() 114 | continue 115 | 116 | if addr[0] == self.local_addr: 117 | continue 118 | 119 | try: 120 | await self.process_packet(buffer) 121 | except Exception as e: 122 | self.dprint("Issue processing packet: {}".format(e)) 123 | finally: 124 | gc.collect() 125 | 126 | async def process_packet(self, buffer: bytes) -> None: 127 | parsed_packet = parse_packet(buffer) 128 | if len(self.callbacks) == 0: 129 | if self.print_packets: 130 | print(parsed_packet) 131 | else: 132 | loop = uasyncio.get_event_loop() 133 | for callback in self.callbacks.values(): 134 | loop.create_task(callback.callback(parsed_packet)) 135 | if callback.timedout: 136 | self.remove_if_present(callback) 137 | elif callback.remove_if is not None: 138 | loop.create_task(self.remove_if_check(callback, parsed_packet)) 139 | 140 | async def remove_if_check(self, callback: Callback, message: DNSResponse) -> None: 141 | if await callback.remove_if(message): 142 | return self.remove_if_present(callback) 143 | 144 | def remove_if_present(self, callback: Callback) -> None: 145 | self.remove_id(callback.id) 146 | 147 | def remove_id(self, callback_id: int) -> bool: 148 | deleted = False 149 | if callback_id in self.callbacks: 150 | self.dprint("Removing callback with id {}".format(callback_id)) 151 | del self.callbacks[callback_id] 152 | deleted = True 153 | 154 | if len(self.callbacks) == 0 and not self.print_packets: 155 | self.dprint("Stopping consumption pipeline as no listeners exist") 156 | self.stop() 157 | 158 | return deleted 159 | 160 | async def send_question(self, *questions: DNSQuestion) -> None: 161 | question_wrapper = DNSQuestionWrapper(questions=questions) 162 | self._send_bytes(question_wrapper.to_bytes()) 163 | 164 | async def send_response(self, response: DNSResponse) -> None: 165 | self._send_bytes(response.to_bytes()) 166 | 167 | def _send_bytes(self, payload: bytes) -> None: 168 | self._init_socket_if_not_done() 169 | try: 170 | self.socket.sendto(payload, (MDNS_ADDR, MDNS_PORT)) 171 | except OSError: 172 | # This sendto function sometimes returns an OSError with EBADF 173 | # as a payload. To avoid a failure here, reiinitialize the socket 174 | # and try again once. 175 | self._close_socket() 176 | self._init_socket() 177 | self.socket.sendto(payload, (MDNS_ADDR, MDNS_PORT)) 178 | 179 | def _init_socket_if_not_done(self) -> None: 180 | if self.socket is None: 181 | self._init_socket() 182 | 183 | async def getaddrinfo( 184 | self, 185 | host: "Union[str, bytes, bytearray]", 186 | port: "Union[str, int, None]", 187 | family: int = 0, 188 | type: int = 0, 189 | proto: int = 0, 190 | flags: int = 0, 191 | ) -> "List[Tuple[int, int, int, str, Tuple[str, int]]]": 192 | hostcheck = host 193 | while hostcheck.endswith("."): 194 | hostcheck = hostcheck[:-1] 195 | if hostcheck.endswith(LOCAL_MDNS_SUFFIX) and family in (0, socket.AF_INET): 196 | host, resolved_ip = await self.mdns_getaddr(host) 197 | return [(socket.AF_INET, type or socket.SOCK_STREAM, proto, host, (resolved_ip, port))] 198 | else: 199 | self.dprint("Resolving dns request host {} and port {}".format(host, port)) 200 | return socket.getaddrinfo(host, port, family, type, proto, flags) 201 | 202 | async def mdns_getaddr(self, host: str) -> Tuple[str, str]: 203 | host = host.lower() 204 | self.dprint("Resolving mdns request host {}".format(host)) 205 | response = self.scan_for_response(TYPE_A, host, self.mdns_timeout) 206 | await self.send_question(DNSQuestion(host, TYPE_A, CLASS_IN)) 207 | record = await response 208 | if record is None: 209 | # The original socket implementation returns -202 on the ESP32 as an error code 210 | raise OSError(-202) 211 | 212 | return record.name, a_record_rdata_to_string(record.rdata) 213 | 214 | async def scan_for_response(self, expected_type: int, name: str, timeout: float = 1.5) -> "Optional[DNSRecord]": 215 | def matching_record(dns_response: DNSResponse) -> "Optional[DNSRecord]": 216 | for record in dns_response.records: 217 | if record.record_type == expected_type and record.name == name: 218 | return record 219 | 220 | result = {"data": None, "event": uasyncio.Event()} 221 | 222 | async def scan_response(dns_response: DNSResponse) -> None: 223 | record = matching_record(dns_response) 224 | if record is None: 225 | return None 226 | result["data"] = record 227 | result["event"].set() 228 | 229 | loop = uasyncio.get_event_loop() 230 | loop.create_task(set_after_timeout(result["event"], timeout)) 231 | 232 | async def is_match(dns_response: DNSResponse) -> bool: 233 | return matching_record(dns_response) is not None 234 | 235 | self.add_callback(scan_response, is_match, timeout) 236 | await result["event"].wait() 237 | return result["data"] 238 | 239 | def dprint(self, message: str) -> None: 240 | if self.debug: 241 | print("MDNS: {}".format(message)) 242 | -------------------------------------------------------------------------------- /src/mdns_client/constants.py: -------------------------------------------------------------------------------- 1 | from micropython import const 2 | 3 | MAX_PACKET_SIZE = const(1700) 4 | 5 | MDNS_ADDR = "224.0.0.251" 6 | MDNS_PORT = const(5353) 7 | DNS_TTL = const(2 * 60) 8 | 9 | FLAGS_QR_MASK = const(0x8000) 10 | FLAGS_QR_QUERY = const(0x0000) 11 | FLAGS_QR_RESPONSE = const(0x8000) 12 | FLAGS_QR_AUTHORITATIVE = const(0x0400) 13 | 14 | FLAGS_AA = const(0x0400) 15 | 16 | CLASS_IN = const(1) 17 | CLASS_ANY = const(255) 18 | CLASS_MASK = const(0x7FFF) 19 | CLASS_UNIQUE = const(0x8000) 20 | 21 | TYPE_A = const(1) 22 | TYPE_NS = const(2) 23 | TYPE_CNAME = const(5) 24 | TYPE_SOA = const(6) 25 | TYPE_WKS = const(11) 26 | TYPE_PTR = const(12) 27 | TYPE_MX = const(15) 28 | TYPE_TXT = const(16) 29 | TYPE_AAAA = const(28) 30 | TYPE_SRV = const(33) 31 | TYPE_ANY = const(255) 32 | 33 | DEFAULT_TTL = const(120) 34 | REPEAT_TYPE_FLAG = const(0xC0) 35 | 36 | LOCAL_MDNS_SUFFIX = ".local" 37 | -------------------------------------------------------------------------------- /src/mdns_client/parser.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from collections import namedtuple 3 | 4 | from mdns_client.constants import REPEAT_TYPE_FLAG, TYPE_CNAME, TYPE_NS, TYPE_PTR, TYPE_SOA, TYPE_SRV 5 | from mdns_client.structs import DNSQuestion, DNSRecord, DNSResponse 6 | from mdns_client.util import end_index_of_name 7 | 8 | MDNSPacketHeader = namedtuple( 9 | "MDNSPacketHeader", 10 | ["transaction_id", "message_type", "num_questions", "num_answers", "num_authorities", "num_additional"], 11 | ) 12 | 13 | 14 | def parse_packet(buffer: bytes) -> "Optional[DNSResponse]": 15 | packet_parser = PacketParser(buffer) 16 | return packet_parser.parse() 17 | 18 | 19 | class PacketParser: 20 | def __init__(self, buffer: bytes) -> None: 21 | self.buffer = buffer 22 | self.index = 0 23 | self.header = MDNSPacketHeader(*self._unpack("!HHHHHH", 6)) 24 | self.index = 12 25 | 26 | def parse(self) -> "Optional[DNSResponse]": 27 | questions = self.parse_questions() 28 | answers = self.parse_answers() 29 | authorities = self.parse_authorities() 30 | additionals = self.parse_additionals() 31 | return DNSResponse( 32 | self.header.transaction_id, 33 | self.header.message_type, 34 | questions, 35 | answers, 36 | authorities, 37 | additionals, 38 | ) 39 | 40 | def parse_questions(self) -> "List[DNSQuestion]": 41 | return [self.parse_question() for _ in range(self.header.num_questions)] 42 | 43 | def parse_question(self) -> DNSQuestion: 44 | record_name = self._parse_record_name().lower() 45 | type_query, query_class = self._unpack("!HH", 4) 46 | return DNSQuestion(record_name, type_query, query_class) 47 | 48 | def parse_records(self, num_records: int) -> "List[DNSRecord]": 49 | return [self.parse_record() for _ in range(num_records)] 50 | 51 | def parse_record(self) -> DNSRecord: 52 | record_name = self._parse_record_name() 53 | record_type, query_class, time_to_live = self._unpack("!HHL", 8) 54 | record_entry = self._parse_record_entry() 55 | if record_type in (TYPE_PTR, TYPE_NS, TYPE_CNAME): 56 | # The payload is a string and might be compacted. Unpacking the name payload. 57 | record_entry = self._expand_name(record_entry) 58 | elif record_type == TYPE_SRV: 59 | record_entry = self._parse_srv_entry(record_entry) 60 | elif record_type == TYPE_SOA: 61 | record_entry = self._parse_soa_entry(record_entry) 62 | return DNSRecord(record_name, record_type, query_class, time_to_live, record_entry) 63 | 64 | def _parse_srv_entry(self, record_entry: bytes) -> bytes: 65 | # The first 6 bytes are metadata, everything afterwards is a name which might need expansion. 66 | expanded_name = self._expand_name(record_entry[6:]) 67 | return record_entry[:6] + expanded_name 68 | 69 | def _parse_soa_entry(self, record_entry: bytes) -> bytes: 70 | # Two names are encoded which need to eventually be expanded. 71 | # All information afterwards does not require expansion 72 | mname_end_index = end_index_of_name(record_entry, 0) 73 | mname = self._expand_name(record_entry[0:mname_end_index]) 74 | rname_end_index = end_index_of_name(record_entry, mname_end_index) 75 | rname = self._expand_name(record_entry[mname_end_index:rname_end_index]) 76 | return mname + rname + record_entry[rname_end_index:] 77 | 78 | def _parse_mx_entry(self, record_entry: bytes) -> bytes: 79 | # First 16 byte are preference flag, afterwards the domain name 80 | # is specified 81 | expanded_name = self._expand_name(record_entry[16:]) 82 | return record_entry[16:] + expanded_name 83 | 84 | def _expand_name(self, string_bytes: bytes) -> bytes: 85 | payload = b"" 86 | index = 0 87 | while index < len(string_bytes): 88 | length = string_bytes[index] 89 | if length & REPEAT_TYPE_FLAG == REPEAT_TYPE_FLAG: 90 | original_index = self.index 91 | length_tuple = struct.unpack_from("!H", string_bytes, index) 92 | self.index = length_tuple[0] ^ (REPEAT_TYPE_FLAG << 8) 93 | byte_entry = self._parse_name() 94 | self.index = original_index 95 | index += 2 96 | else: 97 | index += 1 98 | end_index = index + length 99 | byte_entry = string_bytes[index:end_index] 100 | length_byte = struct.pack("!B", len(byte_entry)) 101 | byte_entry = length_byte + byte_entry 102 | index = end_index 103 | 104 | payload += byte_entry 105 | return payload 106 | 107 | def parse_answers(self) -> "List[DNSRecord]": 108 | return self.parse_records(self.header.num_answers) 109 | 110 | def parse_authorities(self) -> "List[DNSRecord]": 111 | return self.parse_records(self.header.num_authorities) 112 | 113 | def parse_additionals(self) -> List[DNSRecord]: 114 | return self.parse_records(self.header.num_additional) 115 | 116 | def _parse_record_name(self) -> str: 117 | fqdn_name = [] 118 | name_bytes = self._parse_name() 119 | index = 0 120 | while name_bytes[index] != 0x00: 121 | size = name_bytes[index] 122 | index += 1 123 | end_index = index + size 124 | fqdn_name.append(name_bytes[index:end_index].decode()) 125 | index = end_index 126 | return ".".join(fqdn_name) 127 | 128 | def _parse_name(self) -> bytes: 129 | payload = b"" 130 | while True: 131 | size = self.buffer[self.index] 132 | if size == 0x00: 133 | payload += bytes((0x00,)) 134 | self.index += 1 135 | break 136 | if size & REPEAT_TYPE_FLAG == REPEAT_TYPE_FLAG: 137 | payload += self._parse_repeat_name() 138 | break 139 | else: 140 | payload += self._parse_bytes() 141 | return payload 142 | 143 | def _parse_repeat_name(self) -> bytes: 144 | offset_tuple = self._unpack("!H", 2) 145 | offset = offset_tuple[0] ^ (REPEAT_TYPE_FLAG << 8) 146 | real_index = self.index 147 | self.index = offset 148 | record_name = self._parse_name() 149 | self.index = real_index 150 | return record_name 151 | 152 | def _parse_bytes(self) -> bytes: 153 | size = self.buffer[self.index] 154 | self.index += 1 155 | return bytes((size,)) + self._parse_bytes_of(int(size)) 156 | 157 | def _parse_record_entry(self) -> bytes: 158 | size_tuple = self._unpack("!H", 2) 159 | return self._parse_bytes_of(size_tuple[0]) 160 | 161 | def _parse_bytes_of(self, length: int) -> bytes: 162 | index = self.index 163 | end_index = index + length 164 | data = self.buffer[index:end_index] 165 | self.index = end_index 166 | return data 167 | 168 | def _unpack(self, format: str, length: int) -> "Tuple[Any]": 169 | unpacked = struct.unpack_from(format, self.buffer, self.index) 170 | self.index += length 171 | return unpacked 172 | -------------------------------------------------------------------------------- /src/mdns_client/responder.py: -------------------------------------------------------------------------------- 1 | import random 2 | from collections import namedtuple 3 | 4 | import uasyncio 5 | 6 | from mdns_client.client import Client 7 | from mdns_client.constants import ( 8 | CLASS_IN, 9 | DEFAULT_TTL, 10 | FLAGS_QR_AUTHORITATIVE, 11 | FLAGS_QR_RESPONSE, 12 | TYPE_A, 13 | TYPE_PTR, 14 | TYPE_SRV, 15 | TYPE_TXT, 16 | ) 17 | from mdns_client.structs import DNSQuestion, DNSRecord, DNSResponse, ServiceProtocol, SRVRecord 18 | from mdns_client.util import dotted_ip_to_bytes, name_to_bytes, txt_data_to_bytes 19 | 20 | Advertisement = namedtuple("Advertisement", ["port", "data", "host"]) 21 | MDNS_SERVICE_DISCOVERY = "_services._dns-sd._udp.local" 22 | 23 | 24 | def generate_random_postfix() -> str: 25 | return str(random.randint(0x800000, 0xFFFFFF))[2:] 26 | 27 | 28 | class Responder: 29 | def __init__( 30 | self, 31 | client: Client, 32 | own_ip: "Union[None, str, Callable[[], str]]", 33 | host: "Union[None, str, Callable[[], str]]" = None, 34 | debug: bool = False, 35 | ) -> None: 36 | self._client = client 37 | self.callback_id = None 38 | self.host_resolver = host 39 | self.own_ip_resolver = own_ip 40 | self._advertisements = {} 41 | self.debug = debug 42 | 43 | @property 44 | def stopped(self) -> bool: 45 | return self.callback_id is None 46 | 47 | @property 48 | def own_ip(self) -> "Optional[str]": 49 | if callable(self.own_ip_resolver): 50 | return self.own_ip_resolver() 51 | return self.own_ip_resolver 52 | 53 | @property 54 | def host(self) -> "Optional[str]": 55 | host_resolver = self.host_resolver 56 | if callable(host_resolver): 57 | host_resolver = host_resolver() 58 | 59 | if host_resolver is None: 60 | # setting once a host name 61 | postfix = self.generate_random_postfix() 62 | self.host_resolver = host_resolver = "micropython-{}".format(postfix) 63 | 64 | return host_resolver 65 | 66 | def generate_random_postfix(self) -> str: 67 | return generate_random_postfix() 68 | 69 | @property 70 | def host_fqdn(self) -> Optional[str]: 71 | host = self.host 72 | if host is None: 73 | return None 74 | 75 | return ".".join((host, "local")).lower() 76 | 77 | def advertise( 78 | self, 79 | protocol: str, 80 | service: str, 81 | port: int, 82 | data: "Optional[Dict[str, Union[List[str], str]]]" = None, 83 | service_host_name: "Optional[str]" = None, 84 | ) -> None: 85 | service_protocol = ServiceProtocol(protocol, service) 86 | self._advertisements[service_protocol.to_name()] = Advertisement(port, data, service_host_name) 87 | if self.stopped: 88 | self.start() 89 | 90 | def withdraw(self, protocol: str, service: str) -> None: 91 | service_protocol = ServiceProtocol(protocol, service) 92 | name = service_protocol.to_name() 93 | if name in self._advertisements: 94 | del self._advertisements[name] 95 | 96 | def start(self) -> None: 97 | if not self.stopped: 98 | return 99 | 100 | callback = self._client.add_callback(self._on_response) 101 | self.callback_id = callback.id 102 | 103 | def stop(self) -> None: 104 | if self.stopped: 105 | return 106 | 107 | self._client.remove_id(self.callback_id) 108 | self.callback_id = None 109 | 110 | async def _on_response(self, response: DNSResponse) -> None: 111 | if not response.is_request: 112 | return 113 | 114 | for question in response.questions: 115 | self._on_question(question) 116 | 117 | def _on_question(self, question: DNSQuestion) -> None: 118 | if question.type == TYPE_PTR: 119 | self._on_ptr_question(question) 120 | elif question.type == TYPE_SRV: 121 | self._on_srv_question(question) 122 | elif question.type == TYPE_A: 123 | self._on_a_question(question) 124 | elif question.type == TYPE_TXT: 125 | self._on_txt_question(question) 126 | 127 | def _on_ptr_question(self, question: DNSQuestion) -> None: 128 | query = question.query 129 | if query == MDNS_SERVICE_DISCOVERY: 130 | self._send_service_discovery_ptrs() 131 | return 132 | 133 | if query not in self._advertisements: 134 | return 135 | 136 | self._dprint("Responding to DNS PTR question for {}".format(query)) 137 | ptr_record = self._ptr_record_for(query) 138 | if ptr_record is None: 139 | return 140 | answers = [ptr_record] 141 | additional = [self._srv_record_for(query), self._txt_record_for(query), self._a_record()] 142 | self._send_response(answers, additional) 143 | 144 | def _send_service_discovery_ptrs(self) -> None: 145 | answers = [] 146 | for service in self._advertisements.keys(): 147 | answers.append(DNSRecord(MDNS_SERVICE_DISCOVERY, TYPE_PTR, CLASS_IN, DEFAULT_TTL, name_to_bytes(service))) 148 | self._dprint("Answering service record query with services {}".format(",".join(self._advertisements.keys()))) 149 | self._send_response(answers) 150 | 151 | def _on_srv_question(self, question: DNSQuestion) -> None: 152 | query = question.query 153 | service = self._get_service_of(query) 154 | if service is None: 155 | return 156 | 157 | self._dprint("Responding to DNS SRV question for {}".format(query)) 158 | srv_answers = [self._srv_record_for(service)] 159 | additional = [self._a_record(), self._txt_record_for(service)] 160 | self._send_response(srv_answers, additional) 161 | 162 | def _on_a_question(self, question: DNSQuestion) -> None: 163 | if question.query != self.host_fqdn: 164 | return 165 | 166 | a_record = self._a_record() 167 | if a_record is None: 168 | return 169 | 170 | self._dprint("Responding to DNS A question for {}".format(question.query)) 171 | self._send_response([a_record]) 172 | 173 | def _on_txt_question(self, question: DNSQuestion) -> None: 174 | query = question.query 175 | service = self._get_service_of(query) 176 | if service is None: 177 | return 178 | 179 | txt_record = self._txt_record_for(service) 180 | if txt_record is None: 181 | return 182 | 183 | self._dprint("Responding to DNS TXT question for {}".format(query)) 184 | srv_answers = [txt_record] 185 | self._send_response(srv_answers) 186 | 187 | def _get_service_of(self, query: str) -> "Optional[str]": 188 | query_parts = query.split(".") 189 | if len(query_parts) != 4 or query_parts[-1] != "local": 190 | return 191 | 192 | service = ".".join(query_parts[-3:]) 193 | if service not in self._advertisements: 194 | return None 195 | advertisment = self._advertisements[service] 196 | if query_parts[0] not in (self.host, advertisment.host): 197 | return None 198 | 199 | return service 200 | 201 | def _ptr_record_for(self, query: str) -> "Optional[DNSRecord]": 202 | ptr_target = self._service_name_of(query) 203 | if ptr_target is None: 204 | return None 205 | # For some reason the PTR is shortened and the last two bytes are removed 206 | ptr_target_bytes = name_to_bytes(ptr_target) 207 | return DNSRecord(query, TYPE_PTR, CLASS_IN, DEFAULT_TTL, ptr_target_bytes) 208 | 209 | def _srv_record_for(self, query: str) -> "Optional[DNSRecord]": 210 | advertisment = self._advertisements.get(query, None) 211 | host_fqdn = self.host_fqdn 212 | if advertisment is None or host_fqdn is None: 213 | return None 214 | srv_name = self._service_name_of(query) 215 | assert srv_name is not None 216 | 217 | srv_record = SRVRecord(srv_name, 0, 0, advertisment.port, host_fqdn) 218 | return DNSRecord(srv_name, TYPE_SRV, CLASS_IN, DEFAULT_TTL, srv_record.to_bytes()) 219 | 220 | def _txt_record_for(self, service: str) -> "Optional[DNSRecord]": 221 | advertisment = self._advertisements.get(service, None) 222 | host = self.host 223 | if advertisment is None or host is None: 224 | return None 225 | 226 | txt_data = advertisment.data or {} 227 | 228 | fqdn_name = self._service_name_of(service) 229 | assert fqdn_name is not None 230 | txt_payload = txt_data_to_bytes(txt_data) 231 | 232 | return DNSRecord(fqdn_name, TYPE_TXT, CLASS_IN, DEFAULT_TTL, txt_payload) 233 | 234 | def _service_name_of(self, service: str) -> "Optional[str]": 235 | advertisment = self._advertisements.get(service, None) 236 | host = self.host 237 | if advertisment is None: 238 | return None 239 | host = advertisment.host or host 240 | fqdn_name = ".".join((host, service)) 241 | return fqdn_name.lower() 242 | 243 | def _a_record(self) -> "Optional[DNSRecord]": 244 | host_fqdn = self.host_fqdn 245 | ip_address = self.own_ip 246 | if host_fqdn is None or ip_address is None: 247 | return None 248 | 249 | return DNSRecord(host_fqdn, TYPE_A, CLASS_IN, DEFAULT_TTL, dotted_ip_to_bytes(ip_address)) 250 | 251 | def _send_response( 252 | self, answers: "List[DNSRecord]", additional: "Optional[List[Union[DNSRecord, None]]]" = None 253 | ) -> None: 254 | if additional is None: 255 | additional = [] 256 | additional = [item for item in additional if item is not None] 257 | 258 | msg_type = FLAGS_QR_RESPONSE | FLAGS_QR_AUTHORITATIVE 259 | response = DNSResponse(0x00, msg_type, questions=[], answers=answers, authorities=[], additional=additional) 260 | loop = uasyncio.get_event_loop() 261 | loop.create_task(self._client.send_response(response)) 262 | 263 | def _dprint(self, message: str) -> None: 264 | if self.debug: 265 | print("MDNS Responder: {}".format(message)) 266 | -------------------------------------------------------------------------------- /src/mdns_client/service_discovery/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | 3 | from mdns_client.service_discovery.discovery import ServiceDiscovery 4 | from mdns_client.service_discovery.service_response import ServiceResponse 5 | -------------------------------------------------------------------------------- /src/mdns_client/service_discovery/discovery.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import gc 3 | import time 4 | 5 | import uasyncio 6 | 7 | from mdns_client.client import Client 8 | from mdns_client.constants import CLASS_IN, TYPE_A, TYPE_PTR, TYPE_SRV 9 | from mdns_client.service_discovery.service_response import ServiceResponse 10 | from mdns_client.structs import DNSQuestion, DNSRecord, DNSResponse, ServiceProtocol, SRVRecord 11 | from mdns_client.util import a_record_rdata_to_string, bytes_to_name_list, name_list_to_name 12 | 13 | 14 | record_buffer = namedtuple("RecordBuffer", ["record", "invalid_at"]) 15 | 16 | 17 | class ServiceChange: 18 | def __init__(self) -> None: 19 | self.added = set() 20 | self.removed = set() 21 | self.updated = set() 22 | 23 | @property 24 | def has_update(self) -> bool: 25 | return len(self.added) or len(self.removed) or len(self.updated) 26 | 27 | 28 | class ServiceDiscovery: 29 | def __init__( 30 | self, 31 | client: Client, 32 | debug: bool = False, 33 | a_records_buffer_size: int = 10, 34 | a_records_buffer_timeout_ms: int = 500, 35 | ) -> None: 36 | self.client = client 37 | self.monitored_services = {} 38 | self.started = False 39 | self._records_by_target = {} 40 | self._a_records_by_target_buffer = set() 41 | self._a_records_buffer_size = a_records_buffer_size 42 | self._a_records_buffer_timeout_ms = a_records_buffer_timeout_ms 43 | 44 | self._enqueued_service_records = set() 45 | self._enqueued_target_records = set() 46 | self._service_monitors = set() 47 | self._current_change = ServiceChange() 48 | self.timeout = 2.0 49 | self.debug = debug 50 | 51 | def start_if_necessary(self) -> None: 52 | if self.started: 53 | return 54 | 55 | self.start() 56 | 57 | def add_service_monitor(self, service_monitor: "ServiceMonitor") -> None: 58 | self._service_monitors.add(service_monitor) 59 | 60 | def remove_service_monitor(self, service_monitor: "ServiceMonitor") -> None: 61 | self._service_monitors.remove(service_monitor) 62 | 63 | def start(self) -> None: 64 | if self.started: 65 | raise RuntimeError("Already started") 66 | self.started = True 67 | self.dprint("Start discovery module") 68 | 69 | loop = uasyncio.get_event_loop() 70 | loop.create_task(self._change_loop()) 71 | callback = self.client.add_callback(self._on_response) 72 | self.callback_id = callback.id 73 | 74 | async def _change_loop(self) -> None: 75 | while self.started and not self.client.stopped: 76 | await self._tick() 77 | await uasyncio.sleep(self.timeout) 78 | 79 | async def _tick(self) -> None: 80 | now = time.ticks_ms() 81 | if self.client.stopped: 82 | return 83 | 84 | for services in self.monitored_services.values(): 85 | to_remove = set() 86 | for service in services.values(): 87 | if service.expired_at(now): 88 | self.dprint( 89 | "Service {} expired at {} ticks (Current: {} ticks)".format( 90 | service.name, service.invalid_at, now 91 | ) 92 | ) 93 | to_remove.add(service) 94 | elif service.should_refresh_at(now): 95 | self.dprint( 96 | ( 97 | "Service {} will be refreshed via MDNS with expiry at {} ticks " 98 | "and TTL {} (Current: {} ticks)" 99 | ).format(service.name, service.invalid_at, service.ttl, now) 100 | ) 101 | await service.refresh_with(self.client) 102 | 103 | for to_remove_item in to_remove: 104 | self._remove_item(to_remove_item) 105 | 106 | if self._current_change.has_update: 107 | self._propagate_current_change() 108 | self._current_change = ServiceChange() 109 | 110 | self._clean_up_buffer() 111 | gc.collect() 112 | 113 | def _propagate_current_change(self): 114 | for service_monitor in self._service_monitors: 115 | for added in self._current_change.added: 116 | service_monitor.service_added(added) 117 | for updated in self._current_change.updated: 118 | service_monitor.service_updated(updated) 119 | for removed in self._current_change.removed: 120 | service_monitor.service_removed(removed) 121 | 122 | def stop(self) -> None: 123 | if not self.started: 124 | return 125 | 126 | self.client.remove_id(self.callback_id) 127 | self.monitored_services.clear() 128 | self._records_by_target.clear() 129 | self._a_records_by_target_buffer.clear() 130 | self._service_monitors.clear() 131 | self._enqueued_service_records.clear() 132 | self._enqueued_target_records.clear() 133 | self._current_change = ServiceChange() 134 | self.started = False 135 | 136 | async def query(self, protocol: str, service: str) -> None: 137 | self.start_if_necessary() 138 | service_protocol = ServiceProtocol(protocol, service) 139 | self._register_monitored_service(service_protocol) 140 | loop = uasyncio.get_event_loop() 141 | loop.create_task(self._request_once(service_protocol)) 142 | 143 | def stop_watching(self, protocol: str, service: str) -> None: 144 | self._remove_from_monitor(ServiceProtocol(protocol, service)) 145 | 146 | def current(self, protocol: str, service: str) -> "Iterable[ServiceResponse]": 147 | service_protocol = ServiceProtocol(protocol, service) 148 | return tuple(self.monitored_services.get(service_protocol, {}).values()) 149 | 150 | async def query_once(self, protocol: str, service: str, timeout: float = None) -> "Iterable[ServiceResponse]": 151 | timeout = self.timeout if timeout is None else timeout 152 | started_before = self.started 153 | client_started_before = not self.client.stopped 154 | self.start_if_necessary() 155 | service_protocol = ServiceProtocol(protocol, service) 156 | existed = service_protocol in self.monitored_services 157 | monitored_services = self._register_monitored_service(service_protocol) 158 | await self.query(protocol, service) 159 | 160 | await uasyncio.sleep(timeout) 161 | result = tuple(monitored_services) 162 | if not existed: 163 | self._remove_from_monitor(service_protocol) 164 | 165 | if not started_before: 166 | self.stop() 167 | 168 | if not client_started_before: 169 | self.client.stop() 170 | return result 171 | 172 | def _register_monitored_service(self, service_protocol: ServiceProtocol) -> dict: 173 | if service_protocol not in self.monitored_services: 174 | self.dprint("Monitoring service protocol: {}".format(service_protocol)) 175 | return self.monitored_services.setdefault(service_protocol, dict()) 176 | 177 | def _remove_from_monitor(self, service_protocol: ServiceProtocol) -> None: 178 | if service_protocol not in self.monitored_services: 179 | return 180 | 181 | self.dprint("Removing service protocol from monitoring: {}".format(service_protocol)) 182 | for monitored_service in self.monitored_services[service_protocol]: 183 | self._remove_item(monitored_service) 184 | 185 | def _remove_item(self, service: ServiceResponse) -> None: 186 | self._remove_item_from_target(service.target, service) 187 | self._remove_item_from_target(service.name, service) 188 | 189 | service_dict = self.monitored_services.get(service.protocol, None) 190 | if service in service_dict: 191 | self._current_change.removed.add(service) 192 | del service_dict[service] 193 | 194 | def _remove_item_from_target(self, target: str, service: ServiceResponse) -> None: 195 | target = target.lower() 196 | res = self._records_by_target.get(target, None) 197 | if res: 198 | if service in res: 199 | res.remove(service) 200 | if len(res) == 0: 201 | del self._records_by_target[target] 202 | 203 | async def _request_once(self, service_protocol: ServiceProtocol) -> None: 204 | await self.client.send_question(DNSQuestion(service_protocol.to_name(), TYPE_PTR, CLASS_IN)) 205 | 206 | async def _on_response(self, response: DNSResponse) -> None: 207 | for message in self._records_of(response): 208 | self._on_record(message) 209 | 210 | questions = [] 211 | for service_record in self._enqueued_service_records: 212 | questions.append(DNSQuestion(service_record, TYPE_SRV, CLASS_IN)) 213 | for host_resolve in self._enqueued_target_records: 214 | questions.append(DNSQuestion(host_resolve, TYPE_A, CLASS_IN)) 215 | if len(questions): 216 | await self.client.send_question(*questions) 217 | 218 | gc.collect() 219 | 220 | def _records_of(self, response: DNSResponse) -> "Iterable[DNSRecord]": 221 | return response.records 222 | 223 | def _on_record(self, record: DNSRecord) -> None: 224 | if record.record_type == TYPE_PTR: 225 | self._on_ptr_record(record) 226 | elif record.record_type == TYPE_SRV: 227 | self._on_srv_record(record) 228 | elif record.record_type == TYPE_A: 229 | self._on_a_record(record) 230 | 231 | def _on_ptr_record(self, record: DNSRecord) -> None: 232 | pointer_data = bytes_to_name_list(record.rdata) 233 | if len(pointer_data) < 4: 234 | return 235 | 236 | service_protocol = ServiceProtocol(pointer_data[-3], pointer_data[-2]) 237 | if service_protocol in self.monitored_services: 238 | self._enqueue_srv_for(name_list_to_name(pointer_data)) 239 | 240 | def _enqueue_srv_for(self, srv_name: str) -> None: 241 | self._enqueued_service_records.add(srv_name.lower()) 242 | 243 | def _on_srv_record(self, record: DNSRecord) -> None: 244 | if record.name in self._enqueued_service_records: 245 | self._enqueued_service_records.remove(record.name.lower()) 246 | 247 | srv_name_items = record.name.split(".") 248 | if len(srv_name_items) < 4: 249 | return 250 | 251 | service_protocol = ServiceProtocol(srv_name_items[-3], srv_name_items[-2]) 252 | if service_protocol not in self.monitored_services: 253 | return 254 | 255 | srv_record = SRVRecord.from_dns_record(record) 256 | response = ServiceResponse( 257 | record.name, srv_record.priority, srv_record.weight, srv_record.port, srv_record.target 258 | ) 259 | response.invalid_at = record.invalid_at 260 | response.ttl = record.time_to_live 261 | if response not in self.monitored_services[service_protocol]: 262 | self.dprint("Found new service {}".format(srv_record.name)) 263 | self.monitored_services[service_protocol][response] = response 264 | self._current_change.added.add(response) 265 | else: 266 | self.dprint("Got SRV message for existing service {}".format(srv_record.name)) 267 | old_response = self.monitored_services[service_protocol][response] 268 | 269 | if old_response != response: 270 | for attribute in ["name", "priority", "weight", "port", "ttl"]: 271 | setattr(old_response, attribute, getattr(response, attribute)) 272 | self.dprint("Updating changed service {}".format(srv_record.name)) 273 | old_response.ips.clear() 274 | self._current_change.updated.add(old_response) 275 | old_response.refreshed_at = None 276 | old_response.invalid_at = response.invalid_at 277 | 278 | self._records_by_target.setdefault(response.name.lower(), set()).add(response) 279 | self._records_by_target.setdefault(response.target.lower(), set()).add(response) 280 | self._enqueued_target_records.add(srv_record.target) 281 | 282 | for item in self._a_records_by_target_buffer: 283 | if item.record.name.lower() in self._records_by_target: 284 | self._on_a_record(item.record) 285 | self._a_records_by_target_buffer.remove(item) 286 | 287 | def _on_a_record(self, record: DNSRecord) -> None: 288 | record_name = record.name.lower() 289 | 290 | if record_name in self._enqueued_target_records: 291 | self._enqueued_target_records.remove(record_name) 292 | 293 | if record_name not in self._records_by_target: 294 | self._add_to_a_record_buffer(record) 295 | return 296 | 297 | for item in self._records_by_target[record_name]: 298 | ip_address = a_record_rdata_to_string(record.rdata) 299 | if ip_address not in item.ips: 300 | self.dprint("Updating ip addresses for service {} by adding {}".format(item.name, ip_address)) 301 | item.ips.add(a_record_rdata_to_string(record.rdata)) 302 | if item not in self._current_change.added: 303 | self._current_change.updated.add(item) 304 | 305 | record_invalidation = record.invalid_at 306 | item.invalid_at = min(item.invalid_at or record_invalidation, record_invalidation) 307 | item.ttl = min(item.ttl or record.time_to_live, record.time_to_live) 308 | 309 | def dprint(self, message: str) -> None: 310 | if self.debug: 311 | print("MDNS Discovery: {}".format(message)) 312 | 313 | def _add_to_a_record_buffer(self, record: DNSRecord) -> None: 314 | self.dprint("Adding A record which was not in active discovery to buffer {}".format(record)) 315 | self._a_records_by_target_buffer.add(record_buffer(record, time.ticks_ms() + self._a_records_buffer_timeout_ms)) 316 | self._ensure_no_buffer_overflow() 317 | 318 | def _clean_up_buffer(self) -> None: 319 | if len(self._a_records_by_target_buffer) == 0: 320 | return 321 | self._a_records_by_target_buffer = set( 322 | filter(lambda record_buffer: record_buffer.invalid_at > time.ticks_ms(), self._a_records_by_target_buffer) 323 | ) 324 | 325 | def _ensure_no_buffer_overflow(self) -> None: 326 | if len(self._a_records_by_target_buffer) >= self._a_records_buffer_size: 327 | records_buffer_ordered = sorted( 328 | self._a_records_by_target_buffer, key=lambda record_buffer: record_buffer.invalid_at 329 | ) 330 | records_buffer_ordered = records_buffer_ordered[: self._a_records_buffer_size] 331 | self._a_records_by_target_buffer = set(records_buffer_ordered) 332 | -------------------------------------------------------------------------------- /src/mdns_client/service_discovery/service_monitor.py: -------------------------------------------------------------------------------- 1 | from mdns_client.service_discovery.service_response import ServiceResponse 2 | 3 | 4 | class ServiceMonitor: 5 | def service_added(self, service: ServiceResponse) -> None: 6 | raise NotImplementedError() 7 | 8 | def service_updated(self, service: ServiceResponse) -> None: 9 | raise NotImplementedError() 10 | 11 | def service_removed(self, service: ServiceResponse) -> None: 12 | raise NotImplementedError() 13 | -------------------------------------------------------------------------------- /src/mdns_client/service_discovery/service_response.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from mdns_client.constants import CLASS_IN, TYPE_SRV 4 | from mdns_client.structs import DNSQuestion, SRVMixin 5 | 6 | 7 | class ServiceResponse(SRVMixin): 8 | def __init__(self, name: str, priority: int = 0, weight: int = 0, port: int = 0, target: str = ""): 9 | self.name = name 10 | self.priority = priority 11 | self.weight = weight 12 | self.port = port 13 | self.target = target 14 | self.ips = set() 15 | self.txt_records = None 16 | self.invalid_at = None 17 | self.refreshed_at = None 18 | self.ttl = None 19 | 20 | def __repr__(self) -> str: 21 | if self.txt_records is None: 22 | txt_records = "" 23 | else: 24 | txt_records = " txt={}".format(self.txt_records) 25 | return "".format( 26 | self.name, self.priority, self.weight, self.target, self.port, self.ips, txt_records 27 | ) 28 | 29 | def __hash__(self) -> int: 30 | result = 0 31 | for attribute in ["port", "target"]: 32 | result = result ^ hash(getattr(self, attribute)) 33 | return result 34 | 35 | def __eq__(self, other: "ServiceResponse") -> bool: 36 | if not isinstance(other, ServiceResponse): 37 | return False 38 | 39 | for attribute in ["name", "priority", "weight", "port", "ttl"]: 40 | if getattr(self, attribute) != getattr(other, attribute): 41 | return False 42 | 43 | return True 44 | 45 | @property 46 | def ttl_ms(self) -> "Optional[int]": 47 | if self.ttl is None: 48 | return None 49 | 50 | return self.ttl * 1000 51 | 52 | def should_refresh_at(self, timing: int) -> bool: 53 | if self.invalid_at is None or self.ttl is None: 54 | return False 55 | 56 | if timing >= self.invalid_at: 57 | return True 58 | 59 | difference = self.invalid_at - timing 60 | ttl_suggests_refresh = difference < self.ttl_ms / 2 61 | 62 | if not ttl_suggests_refresh or self.refreshed_at is None: 63 | return ttl_suggests_refresh 64 | 65 | difference = timing - self.refreshed_at 66 | # Refresh every 120 seconds if it is not expired 67 | return not self.expired_at(timing) and difference > 120 * 1000 68 | 69 | def expired_at(self, timing: int) -> bool: 70 | if self.invalid_at is None: 71 | return False 72 | 73 | return timing >= self.invalid_at 74 | 75 | async def refresh_with(self, client: "Client") -> None: 76 | self.refreshed_at = time.ticks_ms() 77 | if client.stopped: 78 | return 79 | await client.send_question(DNSQuestion(self.name, TYPE_SRV, CLASS_IN)) 80 | -------------------------------------------------------------------------------- /src/mdns_client/service_discovery/txt_discovery.py: -------------------------------------------------------------------------------- 1 | from mdns_client.constants import TYPE_A, TYPE_PTR, TYPE_SRV, TYPE_TXT 2 | from mdns_client.service_discovery.discovery import ServiceDiscovery 3 | from mdns_client.structs import DNSRecord, DNSResponse 4 | from mdns_client.util import bytes_to_name_list 5 | 6 | TYPE_KEYS = (TYPE_PTR, TYPE_SRV, TYPE_A, TYPE_TXT) 7 | 8 | 9 | def sort_record_by_type(response: DNSResponse) -> int: 10 | if response.record_type in TYPE_KEYS: 11 | return TYPE_KEYS.index(response.record_type) 12 | return -1 13 | 14 | 15 | class TXTServiceDiscovery(ServiceDiscovery): 16 | def _on_record(self, record: DNSRecord) -> None: 17 | super()._on_record(record) 18 | if record.record_type == TYPE_TXT: 19 | self._on_txt_record(record) 20 | 21 | def _records_of(self, response: DNSResponse) -> "Iterable[DNSRecord]": 22 | return sorted(super()._records_of(response), key=sort_record_by_type) 23 | 24 | def _on_txt_record(self, record: DNSRecord) -> None: 25 | record_name = record.name.lower() 26 | if record_name not in self._records_by_target: 27 | return 28 | 29 | target = self._records_by_target[record_name] 30 | txt_records = bytes_to_name_list(record.rdata) 31 | txt_entries = {} 32 | for txt_record in txt_records: 33 | if "=" in txt_record: 34 | key, value = txt_record.split("=", 1) 35 | txt_entries.setdefault(key, []).append(value) 36 | 37 | for target_item in target: 38 | target_item.txt_records = txt_entries 39 | -------------------------------------------------------------------------------- /src/mdns_client/structs.py: -------------------------------------------------------------------------------- 1 | import time 2 | from collections import namedtuple 3 | from struct import pack_into, unpack_from 4 | 5 | from mdns_client.constants import FLAGS_QR_QUERY, FLAGS_QR_RESPONSE 6 | from mdns_client.util import ( 7 | byte_count_of_lists, 8 | bytes_to_name, 9 | check_name, 10 | fill_buffer, 11 | name_to_bytes, 12 | pack_name, 13 | string_packed_len, 14 | ) 15 | 16 | 17 | class DNSQuestion(namedtuple("DNSQuestion", ["query", "type", "query_class"])): 18 | @property 19 | def checked_query(self) -> "List[bytes]": 20 | return check_name(self.query) 21 | 22 | def to_bytes(self) -> bytes: 23 | checked_query = self.checked_query 24 | query_len = string_packed_len(checked_query) 25 | buffer = bytearray(query_len + 4) 26 | pack_name(buffer, self.checked_query) 27 | pack_into("!HH", buffer, query_len, self.type, self.query_class) 28 | return buffer 29 | 30 | 31 | class DNSQuestionWrapper(namedtuple("DNSQuestionWrapper", ["questions"])): 32 | questions: "List[DNSQuestion]" 33 | 34 | def to_bytes(self) -> bytes: 35 | question_bytes = [question.to_bytes() for question in self.questions] 36 | buffer = bytearray(sum(len(qb) for qb in question_bytes) + 12) 37 | buffer[:12] = FLAGS_QR_QUERY.to_bytes(12, "big") 38 | buffer[4:6] = len(self.questions).to_bytes(2, "big") 39 | index = 12 40 | for question_bytes_item in question_bytes: 41 | end = index + len(question_bytes_item) 42 | buffer[index:end] = question_bytes_item 43 | index = end 44 | return buffer 45 | 46 | 47 | class DNSRecord(namedtuple("DNSRecord", ["name", "record_type", "query_class", "time_to_live", "rdata"])): 48 | name: str 49 | record_type: int 50 | query_class: int 51 | time_to_live: int 52 | rdata: bytes 53 | 54 | @property 55 | def checked_name(self) -> "List[bytes]": 56 | return check_name(self.name) 57 | 58 | def to_bytes(self) -> bytes: 59 | checked_name = self.checked_name 60 | # Require a null bit in the end of the string 61 | query_len = string_packed_len(checked_name) 62 | header_length = query_len + 10 63 | rdata_length = len(self.rdata) 64 | buffer = bytearray(header_length + rdata_length) 65 | pack_name(buffer, checked_name) 66 | index = query_len 67 | pack_into("!HHLH", buffer, index, self.record_type, self.query_class, self.time_to_live, rdata_length) 68 | index += 10 69 | end_index = index + rdata_length 70 | buffer[index:end_index] = self.rdata 71 | return buffer 72 | 73 | @property 74 | def invalid_at(self) -> int: 75 | return time.ticks_ms() + self.time_to_live * 1000 76 | 77 | 78 | class DNSResponse( 79 | namedtuple("DNSResponse", ["transaction_id", "message_type", "questions", "answers", "authorities", "additional"]) 80 | ): 81 | transaction_id: int 82 | message_type: int 83 | questions: "List[DNSQuestion]" 84 | answers: "List[DNSRecord]" 85 | authorities: "List[DNSRecord]" 86 | additional: "List[DNSRecord]" 87 | 88 | @property 89 | def is_response(self) -> bool: 90 | return self.message_type & FLAGS_QR_RESPONSE == FLAGS_QR_RESPONSE 91 | 92 | @property 93 | def is_request(self) -> bool: 94 | return not self.is_response 95 | 96 | @property 97 | def records(self) -> "Iterable[DNSRecord]": 98 | yield from self.answers 99 | yield from self.authorities 100 | yield from self.additional 101 | 102 | def to_bytes(self) -> bytes: 103 | question_bytes = [question.to_bytes() for question in self.questions] 104 | answer_bytes = [answer.to_bytes() for answer in self.answers] 105 | authorities_bytes = [authority.to_bytes() for authority in self.authorities] 106 | additional_bytes = [additional.to_bytes() for additional in self.additional] 107 | payload_length = byte_count_of_lists(question_bytes, answer_bytes, authorities_bytes, additional_bytes) 108 | buffer = bytearray(12 + payload_length) 109 | pack_into( 110 | "!HHHHHH", 111 | buffer, 112 | 0, 113 | self.transaction_id, 114 | self.message_type, 115 | len(question_bytes), 116 | len(answer_bytes), 117 | len(authorities_bytes), 118 | len(additional_bytes), 119 | ) 120 | index = 12 121 | for question_byte_list in question_bytes: 122 | index = fill_buffer(buffer, question_byte_list, index) 123 | for answer_byte_list in answer_bytes: 124 | index = fill_buffer(buffer, answer_byte_list, index) 125 | for authority_byte_list in authorities_bytes: 126 | index = fill_buffer(buffer, authority_byte_list, index) 127 | for additional_byte_list in additional_bytes: 128 | index = fill_buffer(buffer, additional_byte_list, index) 129 | return buffer 130 | 131 | 132 | class ServiceProtocol(namedtuple("ServiceProtocol", ["protocol", "service"])): 133 | @property 134 | def domain(self) -> str: 135 | return "local" 136 | 137 | def to_name(self) -> str: 138 | return "{}.{}.{}".format(self.protocol, self.service, self.domain).lower() 139 | 140 | 141 | ServiceResponse = namedtuple("ServiceResponse", ["priority", "weight", "port", "target"]) 142 | 143 | 144 | class SRVMixin: 145 | name: str 146 | 147 | @property 148 | def protocol(self) -> ServiceProtocol: 149 | service_name_data = self.name.split(".") 150 | return ServiceProtocol(service_name_data[-3], service_name_data[-2]) 151 | 152 | 153 | class SRVRecord(namedtuple("SRVRecord", ["name", "priority", "weight", "port", "target"]), SRVMixin): 154 | @classmethod 155 | def from_dns_record(cls, dns_record: DNSRecord) -> "SRVRecord": 156 | name = dns_record.name 157 | priority, weight, port = unpack_from("!HHH", dns_record.rdata, 0) 158 | target = bytes_to_name(dns_record.rdata[6:]).lower() 159 | return SRVRecord(name, priority, weight, port, target) 160 | 161 | def to_bytes(self) -> bytes: 162 | target_name = name_to_bytes(self.target) 163 | buffer = bytearray(6 + len(target_name)) 164 | pack_into("!HHH", buffer, 0, self.priority, self.weight, self.port) 165 | buffer[6:] = target_name 166 | return buffer 167 | -------------------------------------------------------------------------------- /src/mdns_client/util.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | import uasyncio 4 | 5 | from mdns_client.constants import REPEAT_TYPE_FLAG, TYPE_CNAME, TYPE_MX, TYPE_NS, TYPE_PTR, TYPE_SOA, TYPE_SRV 6 | 7 | 8 | def dotted_ip_to_bytes(ip: str) -> bytes: 9 | """ 10 | Convert a dotted IPv4 address string into four bytes, with 11 | some sanity checks 12 | """ 13 | ip_ints = [int(i) for i in ip.split(".")] 14 | if len(ip_ints) != 4 or any(i < 0 or i > 255 for i in ip_ints): 15 | raise ValueError 16 | return bytes(ip_ints) 17 | 18 | 19 | def bytes_to_dotted_ip(a: "Iterable[int]") -> str: 20 | """ 21 | Convert four bytes into a dotted IPv4 address string, without any 22 | sanity checks 23 | """ 24 | return ".".join(str(i) for i in a) 25 | 26 | 27 | def check_name(n: str) -> "List[bytes]": 28 | """ 29 | Ensure that a name is in the form of a list of encoded blocks of 30 | bytes, typically starting as a qualified domain name 31 | """ 32 | if isinstance(n, str): 33 | n = n.split(".") 34 | if n[-1] == "": 35 | n = n[:-1] 36 | n = [i.encode("UTF-8") if isinstance(i, str) else i for i in n] 37 | return n 38 | 39 | 40 | def string_packed_len(byte_list: "List[bytes]") -> int: 41 | return sum(len(i) + 1 for i in byte_list) + 1 42 | 43 | 44 | def name_to_bytes(name: str) -> bytes: 45 | name_bytes = check_name(name) 46 | buffer = bytearray(string_packed_len(name_bytes)) 47 | pack_name(buffer, name_bytes) 48 | return buffer 49 | 50 | 51 | def pack_name(buffer: bytes, string: "List[bytes]") -> None: 52 | """ 53 | Pack a string into the start of the buffer 54 | We don't support writing with name compression, BIWIOMS 55 | """ 56 | output_index = 0 57 | for part in string: 58 | part_length = len(part) 59 | buffer[output_index] = part_length 60 | after_size_next_index = output_index + 1 61 | end_of_pack_name_index = after_size_next_index + part_length 62 | buffer[after_size_next_index:end_of_pack_name_index] = part 63 | output_index = end_of_pack_name_index 64 | buffer[output_index] = 0 65 | 66 | 67 | def string_to_bytes(item: str) -> bytes: 68 | buffer = bytearray(len(item) + 1) 69 | buffer[0] = len(item) 70 | buffer[1:] = item.encode("utf-8") 71 | return buffer 72 | 73 | 74 | def might_have_repeatable_payload(record_type: int) -> bool: 75 | return record_type in (TYPE_NS, TYPE_CNAME, TYPE_PTR, TYPE_SOA, TYPE_MX, TYPE_SRV) 76 | 77 | 78 | def byte_count_of_lists(*list_of_lists: "Iterable[bytes]") -> int: 79 | return sum(sum(len(item) for item in byte_list) for byte_list in list_of_lists) 80 | 81 | 82 | def fill_buffer(buffer: bytes, item: bytes, offset: int) -> int: 83 | end_offset = offset + len(item) 84 | buffer[offset:end_offset] = item 85 | return end_offset 86 | 87 | 88 | def end_index_of_name(buffer: bytes, offset: int) -> int: 89 | """ 90 | Expects the offset to be in the beginning of a name and 91 | scans through the buffer. It returns the last index of the 92 | string representation. 93 | """ 94 | while offset < len(buffer): 95 | string_part_length = buffer[offset] 96 | if string_part_length & REPEAT_TYPE_FLAG == REPEAT_TYPE_FLAG: 97 | # Repeat type flags are always at the end. Meaning the reference 98 | # should be dereferenced and then the name is completed 99 | return offset + 2 100 | elif string_part_length == 0x00: 101 | return offset + 1 102 | offset += string_part_length 103 | 104 | raise IndexError("Could not idenitfy end of index") 105 | 106 | 107 | def bytes_to_name(data: bytes) -> str: 108 | item = bytes_to_name_list(data) 109 | return name_list_to_name(item) 110 | 111 | 112 | def name_list_to_name(data: "List[str]") -> str: 113 | return ".".join(data) 114 | 115 | 116 | def bytes_to_name_list(data: bytes) -> "List[str]": 117 | index = 0 118 | item = [] 119 | data_length = len(data) 120 | while index < data_length: 121 | length_byte = data[index] 122 | if length_byte == 0x00: 123 | break 124 | 125 | index += 1 126 | end_index = index + length_byte 127 | data_item = data[index:end_index] 128 | item.append(data_item.decode("utf-8")) 129 | index = end_index 130 | return item 131 | 132 | 133 | def a_record_rdata_to_string(rdata: bytes) -> str: 134 | ip_numbers = struct.unpack("!BBBB", rdata) 135 | return ".".join(str(ip_number) for ip_number in ip_numbers) 136 | 137 | 138 | async def set_after_timeout(event: uasyncio.Event, timeout: float): 139 | await uasyncio.sleep(timeout) 140 | event.set() 141 | 142 | 143 | def txt_data_to_bytes(txt_data: "Dict[str, Union[str, List[str]]]") -> bytes: 144 | payload = b"" 145 | for key, values in txt_data.items(): 146 | if isinstance(values, str): 147 | values = [values] 148 | for value in values: 149 | if value is None: 150 | value = "" 151 | payload += string_to_bytes("{}={}".format(key, value)) 152 | return payload 153 | -------------------------------------------------------------------------------- /src/setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import List 4 | 5 | from setuptools import find_namespace_packages, setup 6 | 7 | sys.path.append(os.path.join(os.path.dirname(__file__), "src")) 8 | 9 | CURRENT_PYTHON = sys.version_info[:2] 10 | REQUIRED_PYTHON = (3, 11) 11 | EGG_NAME = "micropython-mdns" 12 | 13 | 14 | def list_packages(source_directory: str = ".") -> List[str]: 15 | packages = list(find_namespace_packages(source_directory, exclude="venv")) 16 | return packages 17 | 18 | 19 | __version__ = "1.6.0" 20 | requirements = [] 21 | test_requirements = ["twine", "adafruit-ampy>=1.0.0"] 22 | 23 | readme_location = "README.md" 24 | if os.path.isfile(readme_location): 25 | with open(readme_location, "r") as handle: 26 | long_description = handle.read() 27 | else: 28 | long_description = "" 29 | 30 | 31 | setup( 32 | name=EGG_NAME, 33 | version=__version__, 34 | python_requires=">={}.{}".format(*REQUIRED_PYTHON), 35 | url="https://github.com/cbrand/micropython-mdns", 36 | author="Christoph Brand", 37 | author_email="ch.brand@gmail.com", 38 | description="MDNS for micropython with service discovery support", 39 | long_description=long_description, 40 | long_description_content_type="text/markdown", 41 | license="MIT", 42 | packages=list_packages(), 43 | include_package_data=True, 44 | install_requires=requirements, 45 | zip_safe=True, 46 | classifiers=[ 47 | "Development Status :: 5 - Production/Stable", 48 | "Intended Audience :: Developers", 49 | "Programming Language :: Python :: Implementation :: MicroPython", 50 | "Programming Language :: Python", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "Programming Language :: Python :: 3.13", 55 | "Programming Language :: Python :: 3 :: Only", 56 | "Topic :: Software Development :: Libraries :: Python Modules", 57 | "Topic :: System :: Networking", 58 | "License :: OSI Approved :: MIT License", 59 | ], 60 | extras_require={}, 61 | project_urls={"GitHub": "https://github.com/cbrand/micropython-mdns"}, 62 | ) 63 | --------------------------------------------------------------------------------