├── .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 | [ ](https://pypi.org/project/micropython-mdns/)
4 |
5 | 
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 | [](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 | [](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 | [](src/mdns_client/service_discovery/discovery.py#24)
177 | [](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 | [](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 | [](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 | [](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 | [](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 |
--------------------------------------------------------------------------------