diff --git a/.github/workflows/mdns.yml b/.github/workflows/mdns.yml new file mode 100644 index 00000000..4d68212f --- /dev/null +++ b/.github/workflows/mdns.yml @@ -0,0 +1,58 @@ +# mdns from a bare clone: the unix port's own test, the unix port against +# python-zeroconf both ways, and the windows port cross-built (it needs +# Windows' mDNS service to run, which a Linux runner hasn't got). +name: mdns + +on: + push: + branches: [main] + paths: + - 'modules/mdns/**' + - 'build_mp.py' + - '.github/workflows/mdns.yml' + pull_request: + paths: + - 'modules/mdns/**' + - 'build_mp.py' + - '.github/workflows/mdns.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + unix: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + + - name: Install the unix port's build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libffi-dev pkg-config + + - name: Build the unix port with mdns + run: python3 build_mp.py --port unix --modules mdns + + - name: The API, its errors, and a service found by the process that advertised it + run: ./builds/unix/standard/micropython modules/mdns/tests/test_mdns.py + + - name: zeroconf finds MicroPython's service, and MicroPython finds zeroconf's + run: | + python3 -m pip install zeroconf + python3 modules/mdns/tests/check_mdns_zeroconf.py ./builds/unix/standard/micropython + + windows: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + + - name: Install the MinGW cross toolchain + run: | + sudo apt-get update + sudo apt-get install -y gcc-mingw-w64-x86-64 libtool libltdl-dev autoconf automake + + - name: Cross-build the windows port with mdns + run: python3 build_mp.py --port windows --modules mdns diff --git a/.gitignore b/.gitignore index 1dfbd3ee..f10bd8f5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ !/modules/tsmux/ !/modules/ppa/ !/modules/jpegio/ +!/modules/mdns/ !/modules/pngio/ !/modules/tflite/ !/modules/vision/ diff --git a/README.md b/README.md index 722cbf38..6ec3e8f4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ New here? The [newcomer's guide](docs/newcomers.md) explains the model; the Display, or serves them as live HLS. - [`h264enc/`](modules/h264enc/): RGB565 frames to H.264 on the ESP32-P4's hardware encoder. - [`jpegio/`](modules/jpegio/): JPEG decode and encode, with CircuitPython's `jpegio` API. + - [`mdns/`](modules/mdns/): find and advertise services on the local network, with + CircuitPython's `mdns` API (esp32, rp2, unix, windows). - [`pngio/`](modules/pngio/): PNG encode and decode, RGB565 in and out. - [`ppa/`](modules/ppa/): scale, rotate, convert and fill pixel buffers, on the ESP32-P4's Pixel Processing Accelerator or in portable C elsewhere. diff --git a/docs/build-plan.md b/docs/build-plan.md index adf94197..0f8b4f2e 100644 --- a/docs/build-plan.md +++ b/docs/build-plan.md @@ -37,6 +37,7 @@ micropython-pydevices/ │ ├── all/manifest.py tracked: every sibling with a root manifest.py, except opt-in ones │ ├── castif/ tracked: lives only here (moves from usermods/); ESP32-P4 only │ ├── jpegio/ tracked: lives only here (moved from displayif, 2026-10-06); every port +│ ├── mdns/ tracked: lives only here; esp32, rp2, unix, windows │ ├── audiodsp -> ../../audiodsp ignored symlinks to sibling checkouts, │ ├── audioif -> ../../audioif clones at the modules.lock commit anywhere else │ ├── audiocomponents, cameraif, displayif, lvgl-micropython, palettes, diff --git a/docs/newcomers.md b/docs/newcomers.md index b1472518..9108fa4d 100644 --- a/docs/newcomers.md +++ b/docs/newcomers.md @@ -298,6 +298,7 @@ way autosize grows the app partition when the image doesn't fit. | [`modules/castif/`](../modules/castif/) | Casts an ESP32-P4's screen and sound to a TV over Wi-Fi Display, or serves them as live HLS. | | [`modules/h264enc/`](../modules/h264enc/) | RGB565 frames to H.264 on the ESP32-P4's hardware encoder. | | [`modules/jpegio/`](../modules/jpegio/) | JPEG decode and encode, with CircuitPython's `jpegio` API. | +| [`modules/mdns/`](../modules/mdns/) | Find and advertise services on the local network, with CircuitPython's `mdns` API. | | [`modules/pngio/`](../modules/pngio/) | PNG encode and decode, RGB565 in and out. | | [`modules/ppa/`](../modules/ppa/) | Scale, rotate, convert and fill pixel buffers, on the ESP32-P4's Pixel Processing Accelerator or in portable C elsewhere. | | [`modules/tsmux/`](../modules/tsmux/) | MPEG-TS and HLS segments from H.264. | diff --git a/modules.lock b/modules.lock index 743320d5..ced557d0 100644 --- a/modules.lock +++ b/modules.lock @@ -11,7 +11,7 @@ displayif https://github.com/PyDevices/displayif.git be7669ebd3 lvgl-micropython https://github.com/PyDevices/lvgl-micropython.git e8aea6672566ff0bbcb62f2f8e91733c31ed4ca1 palettes https://github.com/PyDevices/palettes.git f3dfad0bac281f1e8816b30861445498b4137de6 pdwidgets https://github.com/PyDevices/pdwidgets.git e92267f2ef938b60ba3b97177ceb2bd0e0d402d3 -pydevices https://github.com/PyDevices/pydevices.git 883775566fe85543aca883bcd7825bbe3f9c738a +pydevices https://github.com/PyDevices/pydevices.git 81290b13573b51889def33e65559fe64fb5471c1 pygraphics https://github.com/PyDevices/pygraphics.git 2d7ca35847db78d450428cdc0cb5aa69ab81c809 ulab https://github.com/v923z/micropython-ulab.git 1d3ddd8f5228e2c7335dac71f4846e6abb0ec9f9 usbif https://github.com/PyDevices/usbif.git d1d1812513df03e0e1b90413af4b5dcde272e525 diff --git a/modules/mdns/README.md b/modules/mdns/README.md new file mode 100644 index 00000000..5bfad596 --- /dev/null +++ b/modules/mdns/README.md @@ -0,0 +1,124 @@ +# mdns — find and advertise services on the local network + +`import mdns` lets a board or a desktop announce what it offers on the +network (a web page, a speaker, a remote control) and find what other devices +offer, the way phones and laptops do. It's CircuitPython's `mdns` module: the +same classes, arguments, defaults and errors, so one program runs on either. + +```python +import mdns, network + +server = mdns.Server(network.WLAN(network.STA_IF)) # None on a computer +server.instance_name = "Kitchen display" +server.advertise_service(service_type="_http", protocol="_tcp", port=80, + txt_records=["path=/"]) + +for s in server.find(service_type="_spotify-connect", protocol="_tcp"): + print(s.instance_name, s.hostname, s.ipv4_address, s.port) +``` + +It builds on four ports, each on what that system already has: + +| Port | How | +|---|---| +| esp32 | ESP-IDF's mDNS component, the one that answers for the board's `.local` | +| rp2 with a CYW43 radio (Pico W, Pico 2 W) | lwIP's mDNS responder and search | +| unix | a responder and a querier of its own | +| windows | Windows' own mDNS service, through its DNS-SD API | + +`--modules all` includes it. The browser build can't send multicast, so it +has no `mdns`, and CircuitPython builds keep CircuitPython's own. On CPython, +`mdns.py` in pydevices-desktop gives the same API over python-zeroconf. + +## API + +### `mdns.Server(network_interface)` + +On a board, `network_interface` is a `network.WLAN` (or `network.LAN` on +esp32), or `None` for whichever has an address. On unix and Windows it's +`None`, or on a computer with more than one network the IPv4 address of the +one to use (`"192.168.1.20"`). Anything else raises `ValueError`. + +One `Server` may be live at a time: a second raises +`RuntimeError("mDNS already initialized")` until the first is `deinit()`ed. + +### `hostname`, `instance_name` + +`hostname` is the name the device answers to as `.local`. It +starts as the board's `network.hostname()`, or the computer's name. Setting +it renames the device for mDNS (on Windows it stays the computer's name, +because Windows' mDNS service answers for no other; setting another raises +`NotImplementedError`). + +`instance_name` is the human-readable name advertised services go by. It's +the hostname until you set it. + +### `find(*, service_type, protocol, timeout=1) -> tuple` + +Every service of `service_type` (`"_http"`) over `protocol` (`"_tcp"` or +`"_udp"`) that answers within `timeout` seconds, as `RemoteService`s. The +arguments are keyword-only, as CircuitPython's binding has them. Ctrl-C ends +the wait. + +### `advertise_service(*, service_type, protocol, port, txt_records=None)` + +Answer for a service on `port`. `txt_records` is up to 32 strings, each +`"key=value"` or a key alone. Calling it again for the same `service_type` +and `protocol` changes the port and the records. A `Server` offers up to 8 +services at a time (lwIP on rp2, one). + +### `deinit()` + +Withdraws every service the `Server` advertised and frees its place. A soft +reset does this too. On esp32 the board goes on answering for its hostname, +as it did before. + +### `RemoteService` + +Read-only, made only by `find()`: `hostname` (without `.local`), +`instance_name`, `service_type`, `protocol`, `port`, and `ipv4_address`, a +dotted string or `None` if no address came back. (CircuitPython's is an +`ipaddress.IPv4Address`; `str()` of either is the dotted address.) + +## Tests + +`tests/test_mdns.py` runs on a unix or Windows build: the API, its errors, +and a service the process advertises and finds itself. +`tests/check_mdns_zeroconf.py` runs that build against python-zeroconf in +another process, both ways. + +```bash +./build_mp.py --port unix --modules mdns +./builds/unix/standard/micropython modules/mdns/tests/test_mdns.py +pip install zeroconf +python3 modules/mdns/tests/check_mdns_zeroconf.py ./builds/unix/standard/micropython +``` + +## How + +**unix** binds 224.0.0.251:5353 beside whatever else holds it (Avahi, +systemd-resolved) and answers from a thread: for the hostname, each service's +PTR, SRV and TXT, the DNS-SD service list, and questions asked from other +ports, straight back to the asker. It announces a service twice when it +appears or changes and says goodbye when it goes. It doesn't probe for name +conflicts. `find()` asks from 5353 too, and asks again halfway through, since +multicast gets lost on Wi-Fi. Where 5353 can't be had, `find()` still works, +from a spare port, and `advertise_service()` raises `OSError(EADDRINUSE)`. + +Under WSL with mirrored networking, the Linux side and Windows don't hear +each other's multicast: a unix build and a Windows program on the same +computer can't find each other, though both find, and are found by, every +other device on the network. + +**windows** hands services to Windows' mDNS service (`DnsServiceRegister`), +which holds 5353 and answers for them, and finds with `DnsServiceBrowse` and +`DnsServiceResolve`. Windows may answer for a withdrawn service for a second +or so after `deinit()`. + +**esp32** shares ESP-IDF's mDNS component with the port, which starts it +when the network gets an address. A `Server` starts it only if the port +hasn't, and never stops it. + +**rp2** gives lwIP's responder the station's interface, which the port +doesn't, and takes it off again at `deinit()`. It builds, but hasn't been +run on a Pico W yet. diff --git a/modules/mdns/manifest.py b/modules/mdns/manifest.py new file mode 100644 index 00000000..a3f4d851 --- /dev/null +++ b/modules/mdns/manifest.py @@ -0,0 +1,4 @@ +# mdns: CircuitPython's mdns API (find and advertise services on the local +# network). C only; esp32, rp2 with a network, unix and windows. On CPython, +# pydevices-desktop's mdns.py gives the same API over python-zeroconf. +c_module(".") diff --git a/modules/mdns/micropython.cmake b/modules/mdns/micropython.cmake new file mode 100644 index 00000000..982003a3 --- /dev/null +++ b/modules/mdns/micropython.cmake @@ -0,0 +1,11 @@ +# mdns CMake glue (see micropython.mk): ESP-IDF's mDNS component on esp32, +# which the port already depends on, and lwIP's mDNS on rp2 boards with a +# CYW43 radio. Elsewhere it compiles to nothing. +add_library(usermod_mdns INTERFACE) +target_sources(usermod_mdns INTERFACE + ${CMAKE_CURRENT_LIST_DIR}/src/mod_mdns.c + ${CMAKE_CURRENT_LIST_DIR}/src/mdns_esp32.c + ${CMAKE_CURRENT_LIST_DIR}/src/mdns_lwip.c +) +target_include_directories(usermod_mdns INTERFACE ${CMAKE_CURRENT_LIST_DIR}/src) +target_link_libraries(usermod INTERFACE usermod_mdns) diff --git a/modules/mdns/micropython.mk b/modules/mdns/micropython.mk new file mode 100644 index 00000000..de7b431e --- /dev/null +++ b/modules/mdns/micropython.mk @@ -0,0 +1,23 @@ +# mdns: CircuitPython's mdns API. On the make ports it builds for unix (our +# own responder and querier) and windows (the system's DNS-SD API) only: the +# browser can't send multicast, and CircuitPython has its own mdns module. +MDNS_DIR := $(USERMOD_DIR)/src +MDNS_PORT_DIR := $(abspath $(CURDIR)) + +ifeq ($(wildcard $(TOP)/py/circuitpy_mpconfig.h),) +ifneq ($(findstring /ports/unix,$(MDNS_PORT_DIR))$(findstring /ports/windows,$(MDNS_PORT_DIR)),) + +CFLAGS_USERMOD += -I$(MDNS_DIR) +SRC_USERMOD_C += $(MDNS_DIR)/mod_mdns.c +# No qstrs: they stay out of the QSTR scan. +SRC_USERMOD_LIB_C += $(MDNS_DIR)/mdns_posix.c +SRC_USERMOD_LIB_C += $(MDNS_DIR)/mdns_windows.c + +ifneq ($(findstring /ports/windows,$(MDNS_PORT_DIR)),) +LIBS_USERMOD += -ldnsapi -lws2_32 +else +LIBS_USERMOD += -lpthread +endif + +endif +endif diff --git a/modules/mdns/src/mdns_esp32.c b/modules/mdns/src/mdns_esp32.c new file mode 100644 index 00000000..61248cca --- /dev/null +++ b/modules/mdns/src/mdns_esp32.c @@ -0,0 +1,199 @@ +// mdns on esp32: ESP-IDF's mDNS component (espressif/mdns), the one the port +// already runs for .local. +// +// The port starts it when the station or the LAN gets an address and records +// that in mdns_initialised (ports/esp32/network_wlan.c). A Server shares that +// flag: it starts the component only if the port hasn't, and never frees it, +// because the port's own name lookups (socket.getaddrinfo of a .local name) +// go on using it after a Server is gone. deinit() withdraws the Server's +// services and puts back the port's hostname. +// +// SPDX-License-Identifier: MIT + +#include "mdns_port.h" + +#if MDNS_PORT_ESP32 + +#include + +#include "py/mperrno.h" +#include "py/mphal.h" +#include "py/mpthread.h" +#include "py/runtime.h" + +#include "esp_err.h" +#include "mdns.h" + +#if MICROPY_PY_NETWORK +extern char mod_network_hostname_data[]; +#define PORT_HOSTNAME (mod_network_hostname_data) +#else +#define PORT_HOSTNAME ("mpy-esp32") +#endif + +#if MICROPY_PY_NETWORK_WLAN && (MICROPY_HW_ENABLE_MDNS_QUERIES || MICROPY_HW_ENABLE_MDNS_RESPONDER) +extern bool mdns_initialised; // network_wlan.c's, which network_lan.c shares too +#else +static bool mdns_initialised = false; +#endif + +static void mdns_esp_check(esp_err_t err) { + if (err != ESP_OK) { + mp_raise_OSError(err == ESP_ERR_NO_MEM ? MP_ENOMEM : MP_EIO); + } +} + +static void mdns_esp_start(void) { + if (mdns_initialised) { + return; + } + esp_err_t err = mdns_init(); + // Already started by someone else (an app's own component): share it. + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + mdns_esp_check(err); + } + if (err == ESP_OK) { + mdns_hostname_set(PORT_HOSTNAME); + mdns_instance_name_set(PORT_HOSTNAME); + } + mdns_initialised = true; +} + +void mdns_port_check_interface(mp_obj_t network_interface) { + // network.WLAN or network.LAN; None is whichever has an address. + if (network_interface != mp_const_none) { + qstr name = mp_obj_get_type(network_interface)->name; + if (name != MP_QSTR_WLAN && name != MP_QSTR_LAN) { + mp_raise_ValueError(MP_ERROR_TEXT("mDNS only works with built-in WiFi")); + } + } +} + +void mdns_port_construct(mdns_server_obj_t *self, mp_obj_t network_interface) { + (void)network_interface; + mdns_esp_start(); + char hostname[MDNS_NAME_MAX]; + if (mdns_hostname_get(hostname) != ESP_OK || hostname[0] == '\0') { + mdns_copy_str(hostname, PORT_HOSTNAME, sizeof(hostname)); + } + mdns_copy_str(self->hostname, hostname, sizeof(self->hostname)); +} + +void mdns_port_deinit(mdns_server_obj_t *self) { + for (size_t i = 0; i < self->num_advertised; i++) { + mdns_service_remove(self->advertised[i].service_type, self->advertised[i].protocol); + } + // Put back what the port set, if this Server changed it. + if (strcmp(self->hostname, PORT_HOSTNAME) != 0) { + mdns_hostname_set(PORT_HOSTNAME); + } + if (self->instance_name[0] != '\0') { + mdns_instance_name_set(PORT_HOSTNAME); + } +} + +void mdns_port_set_hostname(mdns_server_obj_t *self, const char *hostname) { + (void)self; + mdns_esp_check(mdns_hostname_set(hostname)); + // The component's task applies it; wait for that, as CircuitPython does. + for (int i = 0; i < 100 && !mdns_hostname_exists(hostname); i++) { + MP_THREAD_GIL_EXIT(); + mp_hal_delay_us(10000); + MP_THREAD_GIL_ENTER(); + } +} + +void mdns_port_set_instance_name(mdns_server_obj_t *self, const char *instance_name) { + (void)self; + mdns_esp_check(mdns_instance_name_set(instance_name)); +} + +static void copy_result(const mdns_result_t *r, mdns_found_t *out) { + mdns_copy_str(out->instance_name, r->instance_name, sizeof(out->instance_name)); + mdns_copy_str(out->hostname, r->hostname, sizeof(out->hostname)); + mdns_copy_str(out->service_type, r->service_type, sizeof(out->service_type)); + mdns_copy_str(out->protocol, r->proto, sizeof(out->protocol)); + out->port = r->port; + out->has_ipv4 = false; + for (const mdns_ip_addr_t *a = r->addr; a != NULL; a = a->next) { + if (a->addr.type == ESP_IPADDR_TYPE_V4) { + memcpy(out->ipv4, &a->addr.u_addr.ip4.addr, 4); // network order + out->has_ipv4 = true; + break; + } + } +} + +size_t mdns_port_find(mdns_server_obj_t *self, const char *service_type, const char *protocol, + mp_int_t timeout_ms, mdns_found_t *out, size_t out_len) { + (void)self; + mdns_search_once_t *search = mdns_query_async_new(NULL, service_type, protocol, MDNS_TYPE_PTR, + timeout_ms, 255, NULL); + if (search == NULL) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Unable to start mDNS query")); + } + mdns_result_t *results = NULL; + uint8_t num_results = 0; + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + for (;;) { + bool done; + MP_THREAD_GIL_EXIT(); + done = mdns_query_async_get_results(search, 10, &results, &num_results); + MP_THREAD_GIL_ENTER(); + if (done) { + break; + } + mp_handle_pending(true); // Ctrl-C ends the search + } + nlr_pop(); + } else { + mdns_query_async_delete(search); + nlr_jump(nlr.ret_val); + } + mdns_query_async_delete(search); + size_t n = 0; + for (mdns_result_t *r = results; r != NULL && n < out_len; r = r->next) { + copy_result(r, &out[n++]); + } + mdns_query_results_free(results); + return n; +} + +static void set_txt(const char *service_type, const char *protocol, const char **txt, const size_t *txt_len, size_t num_txt) { + for (size_t i = 0; i < num_txt; i++) { + // "key=value", or a key alone (a boolean attribute, RFC 6763 6.4). + char key[256]; + size_t k = 0; + while (k < txt_len[i] && txt[i][k] != '=') { + k++; + } + memcpy(key, txt[i], k); + key[k] = '\0'; + const char *value = k < txt_len[i] ? txt[i] + k + 1 : NULL; + size_t value_len = k < txt_len[i] ? txt_len[i] - k - 1 : 0; + esp_err_t err = mdns_service_txt_item_set_with_explicit_value_len(service_type, protocol, key, + value_len ? value : NULL, (uint8_t)value_len); + if (err != ESP_OK) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Failed to add service TXT record")); + } + } +} + +void mdns_port_advertise(mdns_server_obj_t *self, bool update, const char *service_type, const char *protocol, + uint16_t port, const char **txt, const size_t *txt_len, size_t num_txt) { + (void)self; + (void)update; + if (mdns_service_exists(service_type, protocol, NULL)) { + // Ours from an earlier call, or one the firmware added: either way + // this call's port and records replace it. + mdns_esp_check(mdns_service_port_set(service_type, protocol, port)); + mdns_esp_check(mdns_service_txt_set(service_type, protocol, NULL, 0)); + } else { + // NULL: the service takes the Server's instance_name. + mdns_esp_check(mdns_service_add(NULL, service_type, protocol, port, NULL, 0)); + } + set_txt(service_type, protocol, txt, txt_len, num_txt); +} + +#endif // MDNS_PORT_ESP32 diff --git a/modules/mdns/src/mdns_lwip.c b/modules/mdns/src/mdns_lwip.c new file mode 100644 index 00000000..e461c0d9 --- /dev/null +++ b/modules/mdns/src/mdns_lwip.c @@ -0,0 +1,264 @@ +// mdns on rp2 boards with a CYW43 radio (the Pico W and Pico 2 W): lwIP's +// mDNS responder and its search, as CircuitPython's raspberrypi port uses +// them. +// +// The port starts lwIP's responder (mdns_resp_init() in main.c) but gives it +// no interface; a Server adds the station's, under the port's hostname, and +// takes it off again at deinit(). lwIP's default allows one service +// (MDNS_MAX_SERVICES) and two searches at a time. +// +// lwIP calls back from its own context, so the callbacks write only to state +// here, never to the MicroPython heap, and every lwIP call holds its lock. +// +// SPDX-License-Identifier: MIT + +#include "mdns_port.h" + +#if MDNS_PORT_LWIP + +#include + +#include "py/mperrno.h" +#include "py/mphal.h" +#include "py/runtime.h" + +#include "lib/cyw43-driver/src/cyw43.h" +#include "lwip/apps/mdns.h" +#include "lwip/prot/dns.h" + +extern char mod_network_hostname_data[]; + +#define NETIF_STA (&cyw43_state.netif[CYW43_ITF_STA]) + +typedef struct { + char type[MDNS_NAME_MAX]; + char proto[MDNS_PROTO_MAX]; + uint16_t port; + s8_t slot; // lwIP's, or -1 + size_t num_txt; + uint8_t txt_len[MDNS_MAX_TXT_RECORDS]; + char txt[MDNS_MAX_TXT_RECORDS][256]; +} svc_t; + +static struct { + char instance[MDNS_NAME_MAX]; + bool added_netif; // we gave lwIP the station's interface + svc_t svcs[MDNS_MAX_SERVICES]; +} g; + +void mdns_port_check_interface(mp_obj_t network_interface) { + if (network_interface != mp_const_none && mp_obj_get_type(network_interface)->name != MP_QSTR_WLAN) { + mp_raise_ValueError(MP_ERROR_TEXT("mDNS only works with built-in WiFi")); + } +} + +void mdns_port_construct(mdns_server_obj_t *self, mp_obj_t network_interface) { + (void)network_interface; + memset(&g, 0, sizeof(g)); + for (size_t i = 0; i < MDNS_MAX_SERVICES; i++) { + g.svcs[i].slot = -1; + } + MICROPY_PY_LWIP_ENTER + err_t err = ERR_OK; + if (!mdns_resp_netif_active(NETIF_STA)) { + err = mdns_resp_add_netif(NETIF_STA, mod_network_hostname_data); + g.added_netif = err == ERR_OK; + } + MICROPY_PY_LWIP_EXIT + if (err != ERR_OK) { + mp_raise_OSError(MP_ENOMEM); + } + mdns_copy_str(self->hostname, mod_network_hostname_data, sizeof(self->hostname)); + mdns_copy_str(g.instance, mod_network_hostname_data, sizeof(g.instance)); +} + +static void withdraw_all(void) { + MICROPY_PY_LWIP_ENTER + for (size_t i = 0; i < MDNS_MAX_SERVICES; i++) { + if (g.svcs[i].slot >= 0) { + mdns_resp_del_service(NETIF_STA, (u8_t)g.svcs[i].slot); + g.svcs[i].slot = -1; + } + } + MICROPY_PY_LWIP_EXIT +} + +void mdns_port_deinit(mdns_server_obj_t *self) { + (void)self; + withdraw_all(); + memset(g.svcs, 0, sizeof(g.svcs)); + MICROPY_PY_LWIP_ENTER + if (g.added_netif) { + mdns_resp_remove_netif(NETIF_STA); + g.added_netif = false; + } + MICROPY_PY_LWIP_EXIT +} + +static void srv_txt(struct mdns_service *service, void *arg) { + const svc_t *s = arg; + for (size_t i = 0; i < s->num_txt; i++) { + mdns_resp_add_service_txtitem(service, s->txt[i], s->txt_len[i]); + } +} + +static enum mdns_sd_proto proto_of(const char *protocol) { + return strcmp(protocol, "_tcp") == 0 ? DNSSD_PROTO_TCP : DNSSD_PROTO_UDP; +} + +// Give s to lwIP under the current instance name. +static void publish(svc_t *s) { + MICROPY_PY_LWIP_ENTER + s->slot = mdns_resp_add_service(NETIF_STA, g.instance, s->type, proto_of(s->proto), s->port, srv_txt, s); + MICROPY_PY_LWIP_EXIT + if (s->slot < 0) { + s->slot = -1; + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Out of MDNS service slots")); + } +} + +void mdns_port_set_hostname(mdns_server_obj_t *self, const char *hostname) { + (void)self; + MICROPY_PY_LWIP_ENTER + err_t err = mdns_resp_rename_netif(NETIF_STA, hostname); + MICROPY_PY_LWIP_EXIT + if (err != ERR_OK) { + mp_raise_ValueError(MP_ERROR_TEXT("hostname")); + } +} + +void mdns_port_set_instance_name(mdns_server_obj_t *self, const char *instance_name) { + (void)self; + mdns_copy_str(g.instance, instance_name, sizeof(g.instance)); + // lwIP keeps the name per service: offer each again under the new one. + for (size_t i = 0; i < MDNS_MAX_SERVICES; i++) { + svc_t *s = &g.svcs[i]; + if (s->slot >= 0) { + MICROPY_PY_LWIP_ENTER + mdns_resp_del_service(NETIF_STA, (u8_t)s->slot); + MICROPY_PY_LWIP_EXIT + s->slot = -1; + publish(s); + } + } +} + +void mdns_port_advertise(mdns_server_obj_t *self, bool update, const char *service_type, const char *protocol, + uint16_t port, const char **txt, const size_t *txt_len, size_t num_txt) { + (void)self; + (void)update; + svc_t *s = NULL; + for (size_t i = 0; i < MDNS_MAX_SERVICES && !s; i++) { + if (g.svcs[i].slot >= 0 && strcmp(g.svcs[i].type, service_type) == 0 && strcmp(g.svcs[i].proto, protocol) == 0) { + s = &g.svcs[i]; + } + } + for (size_t i = 0; i < MDNS_MAX_SERVICES && !s; i++) { + if (g.svcs[i].slot < 0) { + s = &g.svcs[i]; + } + } + if (s == NULL) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Out of MDNS service slots")); + } + // Remove the existing service first, as CircuitPython does. + if (s->slot >= 0) { + MICROPY_PY_LWIP_ENTER + mdns_resp_del_service(NETIF_STA, (u8_t)s->slot); + MICROPY_PY_LWIP_EXIT + s->slot = -1; + } + mdns_copy_str(s->type, service_type, sizeof(s->type)); + mdns_copy_str(s->proto, protocol, sizeof(s->proto)); + s->port = port; + s->num_txt = num_txt; + for (size_t i = 0; i < num_txt; i++) { + memcpy(s->txt[i], txt[i], txt_len[i]); + s->txt_len[i] = (uint8_t)txt_len[i]; + } + publish(s); +} + +// --- find() -------------------------------------------------------------------- + +static struct { + mdns_found_t *out; + size_t out_len; + size_t n; + volatile u8_t request_id; +} search; + +static void copy_label(char *dest, size_t dest_len, const u8_t *src, size_t len) { + if (len >= dest_len) { + len = dest_len - 1; + } + memcpy(dest, src, len); + dest[len] = '\0'; +} + +// One answering service per frame: its SRV names it and gives the port, its +// A record the host and address (CircuitPython's reading of lwIP's search). +static void search_result(struct mdns_answer *answer, const char *varpart, int varlen, int flags, void *arg) { + (void)arg; + if ((flags & MDNS_SEARCH_RESULT_FIRST) && search.n < search.out_len) { + memset(&search.out[search.n], 0, sizeof(mdns_found_t)); + search.n++; + } + if (search.n == 0 || varlen <= 0) { + return; + } + mdns_found_t *f = &search.out[search.n - 1]; + const u8_t *d = answer->info.domain.name; + if (answer->info.type == DNS_RRTYPE_A && varlen >= 4) { + copy_label(f->hostname, sizeof(f->hostname), d + 1, d[0]); + memcpy(f->ipv4, varpart, 4); + f->has_ipv4 = true; + } else if (answer->info.type == DNS_RRTYPE_SRV && varlen >= 6) { + size_t o = 0; + copy_label(f->instance_name, sizeof(f->instance_name), d + o + 1, d[o]); + o += 1 + d[o]; + copy_label(f->service_type, sizeof(f->service_type), d + o + 1, d[o]); + o += 1 + d[o]; + copy_label(f->protocol, sizeof(f->protocol), d + o + 1, d[o]); + f->port = (uint16_t)(((u8_t)varpart[4] << 8) | (u8_t)varpart[5]); + } +} + +size_t mdns_port_find(mdns_server_obj_t *self, const char *service_type, const char *protocol, + mp_int_t timeout_ms, mdns_found_t *out, size_t out_len) { + (void)self; + search.out = out; + search.out_len = out_len; + search.n = 0; + u8_t request_id; + MICROPY_PY_LWIP_ENTER + err_t err = mdns_search_service(NULL, service_type, proto_of(protocol), NETIF_STA, search_result, NULL, &request_id); + MICROPY_PY_LWIP_EXIT + if (err != ERR_OK) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Unable to start mDNS query")); + } + nlr_buf_t nlr; + mp_uint_t start = mp_hal_ticks_ms(); + if (nlr_push(&nlr) == 0) { + while ((mp_int_t)(mp_hal_ticks_ms() - start) < timeout_ms) { + mp_event_wait_ms(10); // runs lwIP and raises a Ctrl-C + } + nlr_pop(); + } else { + MICROPY_PY_LWIP_ENTER + mdns_search_stop(request_id); + MICROPY_PY_LWIP_EXIT + nlr_jump(nlr.ret_val); + } + MICROPY_PY_LWIP_ENTER + mdns_search_stop(request_id); + MICROPY_PY_LWIP_EXIT + // Each service as asked for, even where a frame left it unnamed. + for (size_t i = 0; i < search.n; i++) { + mdns_copy_str(out[i].service_type, service_type, sizeof(out[i].service_type)); + mdns_copy_str(out[i].protocol, protocol, sizeof(out[i].protocol)); + } + return search.n; +} + +#endif // MDNS_PORT_LWIP diff --git a/modules/mdns/src/mdns_port.h b/modules/mdns/src/mdns_port.h new file mode 100644 index 00000000..7983a176 --- /dev/null +++ b/modules/mdns/src/mdns_port.h @@ -0,0 +1,88 @@ +// mdns: what mod_mdns.c (the Python API, CircuitPython's) asks of a port. +// +// One backend is compiled in, chosen here: ESP-IDF's mDNS component on esp32, +// lwIP's mDNS on rp2, the system's DNS-SD API on Windows, and a responder and +// querier of our own on unix. Every function is called with the GIL held, from +// the thread running Python, and may raise. +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "py/obj.h" + +// The qstr scan sees the port's own definitions, not a module's, so the +// backend is chosen from those. micropython.mk compiles these files only on +// unix and windows, so of the make ports' CYW43 boards only rp2's get here. +#if defined(ESP_PLATFORM) +#define MDNS_PORT_ESP32 (1) +#elif defined(MICROPY_PY_NETWORK_CYW43) && MICROPY_PY_NETWORK_CYW43 && MICROPY_PY_LWIP +#define MDNS_PORT_LWIP (1) +#elif defined(_WIN32) +#define MDNS_PORT_WINDOWS (1) +#elif defined(__unix__) || defined(__APPLE__) +#define MDNS_PORT_POSIX (1) +#endif + +#if MDNS_PORT_ESP32 || MDNS_PORT_WINDOWS || MDNS_PORT_LWIP || MDNS_PORT_POSIX +#define MDNS_AVAILABLE (1) +#else +#define MDNS_AVAILABLE (0) +#endif + +// RFC 6762 Appendix A: a label is 63 bytes at most. +#define MDNS_NAME_MAX (64) +#define MDNS_PROTO_MAX (8) +// CircuitPython's own limits (its rp2 port). +#define MDNS_MAX_TXT_RECORDS (32) +#define MDNS_MAX_SERVICES (8) +// The most services one find() returns. +#define MDNS_FIND_MAX (32) + +typedef struct { + char instance_name[MDNS_NAME_MAX]; + char hostname[MDNS_NAME_MAX]; + char service_type[MDNS_NAME_MAX]; + char protocol[MDNS_PROTO_MAX]; + uint16_t port; + bool has_ipv4; + uint8_t ipv4[4]; +} mdns_found_t; + +typedef struct { + char service_type[MDNS_NAME_MAX]; + char protocol[MDNS_PROTO_MAX]; +} mdns_advertised_t; + +typedef struct _mdns_server_obj_t { + mp_obj_base_t base; + char hostname[MDNS_NAME_MAX]; + // Empty means the hostname, as in CircuitPython. + char instance_name[MDNS_NAME_MAX]; + mdns_advertised_t advertised[MDNS_MAX_SERVICES]; + size_t num_advertised; + bool inited; +} mdns_server_obj_t; + +// Raise ValueError for a network_interface the port can't use. +void mdns_port_check_interface(mp_obj_t network_interface); +// Start, on a network_interface already checked, and fill in self->hostname. +void mdns_port_construct(mdns_server_obj_t *self, mp_obj_t network_interface); +// Withdraw every service self->advertised names, and stop. +void mdns_port_deinit(mdns_server_obj_t *self); +void mdns_port_set_hostname(mdns_server_obj_t *self, const char *hostname); +void mdns_port_set_instance_name(mdns_server_obj_t *self, const char *instance_name); +// Up to out_len services that answered within timeout_ms; how many. +size_t mdns_port_find(mdns_server_obj_t *self, const char *service_type, const char *protocol, + mp_int_t timeout_ms, mdns_found_t *out, size_t out_len); +// Add a service, or (update true) change the port and TXT records of one +// self->advertised already holds. txt[i] is txt_len[i] bytes, "key=value" or "key". +void mdns_port_advertise(mdns_server_obj_t *self, bool update, const char *service_type, const char *protocol, + uint16_t port, const char **txt, const size_t *txt_len, size_t num_txt); + +// Shared by the ports. +void mdns_copy_str(char *dest, const char *src, size_t dest_len); diff --git a/modules/mdns/src/mdns_posix.c b/modules/mdns/src/mdns_posix.c new file mode 100644 index 00000000..d8b7397f --- /dev/null +++ b/modules/mdns/src/mdns_posix.c @@ -0,0 +1,1102 @@ +// mdns on unix: a responder and a querier of our own, on plain sockets. +// +// The responder is a thread bound to 224.0.0.251:5353 beside whatever else +// holds it (Avahi, systemd-resolved: SO_REUSEADDR and SO_REUSEPORT). It +// answers for .local and for each advertised service: the PTR that +// lists it, its SRV and TXT, the DNS-SD service enumeration (RFC 6763 9), and +// legacy unicast questions (RFC 6762 6.7) straight back to the asker. It +// announces a service twice when it appears or changes and says goodbye (TTL +// 0) when it goes. It doesn't probe for name conflicts. +// +// find() asks from 5353 when the responder holds it, and the responder's +// thread hands it every answer that arrives there. Otherwise it asks from a +// spare port, and responders answer such a question straight back to it +// (RFC 6762 6.7). The first way also works where only multicast gets in: +// WSL's mirrored networking delivers the group's traffic but not a +// responder's unicast answer to a spare port. It asks again halfway through +// (multicast gets lost on Wi-Fi) and then asks for any port or address the +// first answers left out. +// +// SPDX-License-Identifier: MIT + +#include "mdns_port.h" + +#if MDNS_PORT_POSIX + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "py/mperrno.h" +#include "py/mphal.h" +#include "py/mpthread.h" +#include "py/runtime.h" + +#define MDNS_GROUP "224.0.0.251" +#define MDNS_PORT (5353) +#define PKT_MAX (9000) +#define TXT_MAX (1300) + +#define T_A (1) +#define T_PTR (12) +#define T_TXT (16) +#define T_SRV (33) +#define T_ANY (255) +#define CLASS_IN (1) +#define CACHE_FLUSH (0x8000) + +#define TTL_HOST (120) +#define TTL_OTHER (4500) + +// --- the responder's state, shared with its thread under lock ------------------ + +typedef struct recs recs_t; +static void take_records(recs_t *r, const uint8_t *msg, size_t len); + +typedef struct { + bool used; + char type[MDNS_NAME_MAX]; + char proto[MDNS_PROTO_MAX]; + uint16_t port; + uint8_t txt[TXT_MAX]; // RDATA: length-prefixed strings + size_t txt_len; +} svc_t; + +static struct { + pthread_mutex_t lock; + pthread_t thread; + bool thread_live; + volatile bool running; + int fd; // bound to 5353, or -1 + int wake[2]; + uint8_t ip[4]; + char host[MDNS_NAME_MAX]; + char instance[MDNS_NAME_MAX]; + svc_t svcs[MDNS_MAX_SERVICES]; + int announce_left; // announcements still to send, a second apart + recs_t *collect; // a find() in progress gathers answers here +} g = { .lock = PTHREAD_MUTEX_INITIALIZER, .fd = -1, .wake = {-1, -1} }; + +// --- writing DNS messages ------------------------------------------------------ + +typedef struct { + uint8_t *buf; + size_t len, cap; + bool overflow; +} wr_t; + +static void put(wr_t *w, const void *p, size_t n) { + if (w->len + n > w->cap) { + w->overflow = true; + return; + } + memcpy(w->buf + w->len, p, n); + w->len += n; +} + +static void put16(wr_t *w, uint16_t v) { + uint8_t b[2] = { (uint8_t)(v >> 8), (uint8_t)v }; + put(w, b, 2); +} + +static void put32(wr_t *w, uint32_t v) { + uint8_t b[4] = { (uint8_t)(v >> 24), (uint8_t)(v >> 16), (uint8_t)(v >> 8), (uint8_t)v }; + put(w, b, 4); +} + +static void put_label(wr_t *w, const char *s, size_t n) { + if (n > 63) { + n = 63; + } + uint8_t len = (uint8_t)n; + put(w, &len, 1); + put(w, s, n); +} + +// A dotted name, label by label; "local" ends every name here. +static void put_dotted(wr_t *w, const char *name) { + const char *s = name; + while (*s) { + const char *dot = strchr(s, '.'); + size_t n = dot ? (size_t)(dot - s) : strlen(s); + if (n) { + put_label(w, s, n); + } + if (!dot) { + break; + } + s = dot + 1; + } +} + +static void put_end(wr_t *w) { + uint8_t z = 0; + put(w, &z, 1); +} + +// Each kind of name we answer for. +static void name_services(wr_t *w) { + put_dotted(w, "_services._dns-sd._udp.local"); + put_end(w); +} + +static void name_type(wr_t *w, const svc_t *s) { + put_label(w, s->type, strlen(s->type)); + put_label(w, s->proto, strlen(s->proto)); + put_dotted(w, "local"); + put_end(w); +} + +static void name_instance(wr_t *w, const svc_t *s) { + // The instance name is one label, dots and spaces and all. + put_label(w, g.instance, strlen(g.instance)); + name_type(w, s); +} + +static void name_host(wr_t *w) { + put_label(w, g.host, strlen(g.host)); + put_dotted(w, "local"); + put_end(w); +} + +typedef enum { R_ENUM, R_PTR, R_SRV, R_TXT, R_A } rkind_t; + +// One resource record; RDLENGTH is filled in after the RDATA. +static void put_rr(wr_t *w, rkind_t kind, const svc_t *s, uint32_t ttl, bool flush) { + uint16_t type; + switch (kind) { + case R_ENUM: + name_services(w); + type = T_PTR; + break; + case R_PTR: + name_type(w, s); + type = T_PTR; + break; + case R_SRV: + name_instance(w, s); + type = T_SRV; + break; + case R_TXT: + name_instance(w, s); + type = T_TXT; + break; + default: + name_host(w); + type = T_A; + break; + } + put16(w, type); + // Shared records (PTR) never set cache-flush; unique ones may. + put16(w, CLASS_IN | ((flush && type != T_PTR) ? CACHE_FLUSH : 0)); + put32(w, ttl); + size_t at = w->len; + put16(w, 0); + size_t start = w->len; + switch (kind) { + case R_ENUM: + name_type(w, s); + break; + case R_PTR: + name_instance(w, s); + break; + case R_SRV: + put16(w, 0); // priority + put16(w, 0); // weight + put16(w, s->port); + name_host(w); + break; + case R_TXT: + if (s->txt_len) { + put(w, s->txt, s->txt_len); + } else { + put_end(w); // one empty string: RFC 6763 6.1 + } + break; + default: + put(w, g.ip, 4); + break; + } + if (!w->overflow) { + size_t n = w->len - start; + w->buf[at] = (uint8_t)(n >> 8); + w->buf[at + 1] = (uint8_t)n; + } +} + +// --- reading DNS messages ------------------------------------------------------ + +// The name at *off as a dotted string (labels joined with '.', following +// compression pointers); *off moves past it. False if it's malformed. +static bool read_name(const uint8_t *msg, size_t len, size_t *off, char *out, size_t out_cap) { + size_t o = *off, n = 0; + bool jumped = false; + int hops = 0; + for (;;) { + if (o >= len) { + return false; + } + uint8_t l = msg[o]; + if (l == 0) { + if (!jumped) { + *off = o + 1; + } + break; + } + if ((l & 0xc0) == 0xc0) { + if (o + 1 >= len || ++hops > 32) { + return false; + } + if (!jumped) { + *off = o + 2; + jumped = true; + } + o = ((size_t)(l & 0x3f) << 8) | msg[o + 1]; + continue; + } + if ((l & 0xc0) != 0 || o + 1 + l > len) { + return false; + } + if (n + l + 2 > out_cap) { + return false; + } + if (n) { + out[n++] = '.'; + } + memcpy(out + n, msg + o + 1, l); + n += l; + o += 1 + l; + } + out[n] = '\0'; + return true; +} + +static uint16_t get16(const uint8_t *p) { + return (uint16_t)((p[0] << 8) | p[1]); +} + +// "..local", compared without case. +static bool is_name(const char *got, const char *a, const char *b) { + char want[3 * MDNS_NAME_MAX + 16]; + if (b) { + snprintf(want, sizeof(want), "%s.%s.local", a, b); + } else { + snprintf(want, sizeof(want), "%s.local", a); + } + return strcasecmp(got, want) == 0; +} + +static bool is_type_name(const char *got, const svc_t *s) { + return is_name(got, s->type, s->proto); +} + +static bool is_instance_name(const char *got, const svc_t *s) { + char want[3 * MDNS_NAME_MAX + 16]; + snprintf(want, sizeof(want), "%s.%s.%s.local", g.instance, s->type, s->proto); + return strcasecmp(got, want) == 0; +} + +// --- the responder ------------------------------------------------------------- + +static void send_to(const uint8_t *pkt, size_t n, const struct sockaddr_in *dst) { + if (g.fd >= 0 && n > 12) { + sendto(g.fd, pkt, n, 0, (const struct sockaddr *)dst, sizeof(*dst)); + } +} + +static void group_addr(struct sockaddr_in *a) { + memset(a, 0, sizeof(*a)); + a->sin_family = AF_INET; + a->sin_port = htons(MDNS_PORT); + a->sin_addr.s_addr = inet_addr(MDNS_GROUP); +} + +// Every record of one service (or all of them, which < 0), plus our address. +// Called with the lock held. +static size_t build_announce(uint8_t *pkt, size_t cap, int which, uint32_t ttl) { + wr_t w = { pkt, 12, cap, false }; + memset(pkt, 0, 12); + pkt[2] = 0x84; + uint16_t count = 0; + for (int i = 0; i < MDNS_MAX_SERVICES; i++) { + const svc_t *s = &g.svcs[i]; + if (!s->used || (which >= 0 && which != i)) { + continue; + } + put_rr(&w, R_ENUM, s, ttl ? TTL_OTHER : 0, true); + put_rr(&w, R_PTR, s, ttl ? TTL_OTHER : 0, true); + put_rr(&w, R_SRV, s, ttl ? TTL_HOST : 0, true); + put_rr(&w, R_TXT, s, ttl ? TTL_OTHER : 0, true); + count += 4; + } + if (ttl) { + put_rr(&w, R_A, NULL, TTL_HOST, true); + count++; + } + if (w.overflow || count == 0) { + return 0; + } + pkt[6] = (uint8_t)(count >> 8); + pkt[7] = (uint8_t)count; + return w.len; +} + +static void announce_locked(void) { + static uint8_t pkt[PKT_MAX]; + size_t n = build_announce(pkt, sizeof(pkt), -1, 1); + struct sockaddr_in dst; + group_addr(&dst); + send_to(pkt, n, &dst); +} + +static void goodbye_locked(int which) { + static uint8_t pkt[PKT_MAX]; + size_t n = build_announce(pkt, sizeof(pkt), which, 0); + struct sockaddr_in dst; + group_addr(&dst); + send_to(pkt, n, &dst); +} + +typedef struct { + rkind_t kind; + int svc; +} want_t; + +#define WANT_MAX (4 * MDNS_MAX_SERVICES + 2) + +static void add_want(want_t *wants, int *n, rkind_t kind, int svc) { + for (int i = 0; i < *n; i++) { + if (wants[i].kind == kind && wants[i].svc == svc) { + return; + } + } + if (*n < WANT_MAX) { + wants[*n].kind = kind; + wants[*n].svc = svc; + (*n)++; + } +} + +static bool type_matches(uint16_t qtype, uint16_t t) { + return qtype == t || qtype == T_ANY; +} + +// Answer one query; called with the lock held. +static void handle_query(const uint8_t *msg, size_t len, const struct sockaddr_in *src) { + if (len < 12 || (msg[2] & 0x80)) { + return; // a response, or nothing + } + // A legacy unicast query comes from a port other than 5353; its answer + // goes straight back, echoing the ID and question, with short TTLs. + bool legacy = ntohs(src->sin_port) != MDNS_PORT; + uint16_t qd = get16(msg + 4); + want_t answers[WANT_MAX], extras[WANT_MAX]; + int na = 0, ne = 0; + size_t off = 12; + size_t q_start = off; + char name[256]; + for (uint16_t i = 0; i < qd; i++) { + if (!read_name(msg, len, &off, name, sizeof(name)) || off + 4 > len) { + return; + } + uint16_t qtype = get16(msg + off); + off += 4; + if (strcasecmp(name, "_services._dns-sd._udp.local") == 0 && type_matches(qtype, T_PTR)) { + for (int k = 0; k < MDNS_MAX_SERVICES; k++) { + if (g.svcs[k].used) { + add_want(answers, &na, R_ENUM, k); + } + } + } + if (is_name(name, g.host, NULL) && type_matches(qtype, T_A)) { + add_want(answers, &na, R_A, -1); + } + for (int k = 0; k < MDNS_MAX_SERVICES; k++) { + const svc_t *s = &g.svcs[k]; + if (!s->used) { + continue; + } + if (is_type_name(name, s) && type_matches(qtype, T_PTR)) { + add_want(answers, &na, R_PTR, k); + add_want(extras, &ne, R_SRV, k); + add_want(extras, &ne, R_TXT, k); + add_want(extras, &ne, R_A, -1); + } + if (is_instance_name(name, s)) { + if (type_matches(qtype, T_SRV)) { + add_want(answers, &na, R_SRV, k); + add_want(extras, &ne, R_A, -1); + } + if (type_matches(qtype, T_TXT)) { + add_want(answers, &na, R_TXT, k); + } + } + } + } + if (na == 0) { + return; + } + static uint8_t pkt[PKT_MAX]; + wr_t w = { pkt, 12, sizeof(pkt), false }; + memset(pkt, 0, 12); + pkt[2] = 0x84; + if (legacy) { + pkt[0] = msg[0]; + pkt[1] = msg[1]; + put(&w, msg + q_start, off - q_start); + pkt[5] = (uint8_t)qd; + pkt[4] = (uint8_t)(qd >> 8); + } + for (int i = 0; i < na; i++) { + const svc_t *s = answers[i].svc >= 0 ? &g.svcs[answers[i].svc] : NULL; + uint32_t ttl = legacy ? 10 : (answers[i].kind == R_SRV || answers[i].kind == R_A ? TTL_HOST : TTL_OTHER); + put_rr(&w, answers[i].kind, s, ttl, !legacy); + } + int nx = 0; + for (int i = 0; i < ne; i++) { + bool dup = false; + for (int j = 0; j < na; j++) { + dup |= answers[j].kind == extras[i].kind && answers[j].svc == extras[i].svc; + } + if (dup) { + continue; + } + const svc_t *s = extras[i].svc >= 0 ? &g.svcs[extras[i].svc] : NULL; + uint32_t ttl = legacy ? 10 : (extras[i].kind == R_SRV || extras[i].kind == R_A ? TTL_HOST : TTL_OTHER); + put_rr(&w, extras[i].kind, s, ttl, !legacy); + nx++; + } + if (w.overflow) { + return; + } + pkt[6] = (uint8_t)(na >> 8); + pkt[7] = (uint8_t)na; + pkt[10] = (uint8_t)(nx >> 8); + pkt[11] = (uint8_t)nx; + if (legacy) { + send_to(pkt, w.len, src); + } else { + struct sockaddr_in dst; + group_addr(&dst); + send_to(pkt, w.len, &dst); + } +} + +static void *responder_thread(void *arg) { + (void)arg; + static uint8_t msg[PKT_MAX]; + while (g.running) { + struct pollfd fds[2] = { + { .fd = g.fd, .events = POLLIN }, + { .fd = g.wake[0], .events = POLLIN }, + }; + pthread_mutex_lock(&g.lock); + int wait_ms = g.announce_left > 0 ? 1000 : -1; + pthread_mutex_unlock(&g.lock); + int rc = poll(fds, 2, wait_ms); + if (!g.running) { + break; + } + if (rc == 0) { + pthread_mutex_lock(&g.lock); + if (g.announce_left > 0) { + g.announce_left--; + announce_locked(); + } + pthread_mutex_unlock(&g.lock); + continue; + } + if (rc < 0) { + continue; + } + if (fds[1].revents & POLLIN) { + char b[16]; + (void)read(g.wake[0], b, sizeof(b)); + } + if (fds[0].revents & POLLIN) { + struct sockaddr_in src; + socklen_t slen = sizeof(src); + ssize_t n = recvfrom(g.fd, msg, sizeof(msg), 0, (struct sockaddr *)&src, &slen); + if (n > 0) { + pthread_mutex_lock(&g.lock); + if (msg[2] & 0x80) { + if (g.collect) { + take_records(g.collect, msg, (size_t)n); + } + } else { + handle_query(msg, (size_t)n, &src); + } + pthread_mutex_unlock(&g.lock); + } + } + } + return NULL; +} + +static void wake_thread(void) { + if (g.wake[1] >= 0) { + (void)write(g.wake[1], "x", 1); + } +} + +// Bind 5353 and start the thread; false if 5353 can't be had. +static bool responder_start(void) { + if (g.thread_live) { + return true; + } + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) { + return false; + } + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + #ifdef SO_REUSEPORT + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); + #endif + struct sockaddr_in a; + memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons(MDNS_PORT); + a.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(fd, (struct sockaddr *)&a, sizeof(a)) != 0) { + close(fd); + return false; + } + struct ip_mreq mreq; + mreq.imr_multiaddr.s_addr = inet_addr(MDNS_GROUP); + memcpy(&mreq.imr_interface.s_addr, g.ip, 4); + if (setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) != 0) { + mreq.imr_interface.s_addr = htonl(INADDR_ANY); + if (setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) != 0) { + close(fd); + return false; + } + } + unsigned char ttl = 255, loop = 1; + setsockopt(fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)); + setsockopt(fd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop)); + struct in_addr iface; + memcpy(&iface.s_addr, g.ip, 4); + setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, &iface, sizeof(iface)); + if (pipe(g.wake) != 0) { + close(fd); + return false; + } + g.fd = fd; + g.running = true; + if (pthread_create(&g.thread, NULL, responder_thread, NULL) != 0) { + g.running = false; + close(fd); + close(g.wake[0]); + close(g.wake[1]); + g.fd = g.wake[0] = g.wake[1] = -1; + return false; + } + g.thread_live = true; + return true; +} + +static void responder_stop(void) { + if (!g.thread_live) { + return; + } + g.running = false; + wake_thread(); + pthread_join(g.thread, NULL); + g.thread_live = false; + close(g.fd); + close(g.wake[0]); + close(g.wake[1]); + g.fd = g.wake[0] = g.wake[1] = -1; +} + +// The IPv4 address multicast leaves from: the one the route to the mDNS +// group uses, or failing that the default route's. +static bool local_ipv4(uint8_t ip[4]) { + static const char *const via[] = { MDNS_GROUP, "8.8.8.8" }; + for (size_t i = 0; i < 2; i++) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) { + return false; + } + struct sockaddr_in a; + memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons(MDNS_PORT); + a.sin_addr.s_addr = inet_addr(via[i]); + socklen_t alen = sizeof(a); + bool ok = connect(fd, (struct sockaddr *)&a, sizeof(a)) == 0 + && getsockname(fd, (struct sockaddr *)&a, &alen) == 0 + && a.sin_addr.s_addr != htonl(INADDR_ANY); + close(fd); + if (ok) { + memcpy(ip, &a.sin_addr.s_addr, 4); + return true; + } + } + return false; +} + +// --- the port API -------------------------------------------------------------- + +void mdns_port_check_interface(mp_obj_t network_interface) { + // None, or on a computer with more than one network the IPv4 address to + // work from. + struct in_addr a; + if (network_interface != mp_const_none + && (!mp_obj_is_str(network_interface) + || inet_pton(AF_INET, mp_obj_str_get_str(network_interface), &a) != 1)) { + mp_raise_ValueError(MP_ERROR_TEXT("network_interface must be None or an IPv4 address")); + } +} + +void mdns_port_construct(mdns_server_obj_t *self, mp_obj_t network_interface) { + uint8_t ip[4]; + if (network_interface == mp_const_none) { + if (!local_ipv4(ip)) { + mp_raise_OSError(MP_EHOSTUNREACH); + } + } else if (mp_obj_is_str(network_interface)) { + // A computer on more than one network: the address to work from. + struct in_addr a; + if (inet_pton(AF_INET, mp_obj_str_get_str(network_interface), &a) != 1) { + mp_raise_ValueError(MP_ERROR_TEXT("network_interface must be None or an IPv4 address")); + } + memcpy(ip, &a.s_addr, 4); + } else { + mp_raise_ValueError(MP_ERROR_TEXT("network_interface must be None or an IPv4 address")); + } + char host[256]; + if (gethostname(host, sizeof(host)) != 0 || host[0] == '\0') { + strcpy(host, "micropython"); + } + char *dot = strchr(host, '.'); + if (dot) { + *dot = '\0'; + } + pthread_mutex_lock(&g.lock); + memcpy(g.ip, ip, 4); + mdns_copy_str(g.host, host, sizeof(g.host)); + mdns_copy_str(g.instance, host, sizeof(g.instance)); + memset(g.svcs, 0, sizeof(g.svcs)); + g.announce_left = 0; + pthread_mutex_unlock(&g.lock); + mdns_copy_str(self->hostname, host, sizeof(self->hostname)); + // Answering for .local needs 5353; finding things doesn't, so + // a host where it can't be had still finds, and advertise_service() says. + responder_start(); +} + +void mdns_port_deinit(mdns_server_obj_t *self) { + (void)self; + pthread_mutex_lock(&g.lock); + goodbye_locked(-1); + memset(g.svcs, 0, sizeof(g.svcs)); + g.announce_left = 0; + pthread_mutex_unlock(&g.lock); + responder_stop(); +} + +void mdns_port_set_hostname(mdns_server_obj_t *self, const char *hostname) { + (void)self; + pthread_mutex_lock(&g.lock); + mdns_copy_str(g.host, hostname, sizeof(g.host)); + g.announce_left = 2; + announce_locked(); + pthread_mutex_unlock(&g.lock); + wake_thread(); +} + +void mdns_port_set_instance_name(mdns_server_obj_t *self, const char *instance_name) { + (void)self; + pthread_mutex_lock(&g.lock); + goodbye_locked(-1); // the old instance names + mdns_copy_str(g.instance, instance_name, sizeof(g.instance)); + g.announce_left = 2; + announce_locked(); + pthread_mutex_unlock(&g.lock); + wake_thread(); +} + +void mdns_port_advertise(mdns_server_obj_t *self, bool update, const char *service_type, const char *protocol, + uint16_t port, const char **txt, const size_t *txt_len, size_t num_txt) { + (void)self; + (void)update; + size_t total = 0; + for (size_t i = 0; i < num_txt; i++) { + total += 1 + txt_len[i]; + } + if (total > TXT_MAX) { + mp_raise_ValueError(MP_ERROR_TEXT("Failed to add service TXT record")); + } + if (!responder_start()) { + mp_raise_OSError(MP_EADDRINUSE); + } + pthread_mutex_lock(&g.lock); + svc_t *s = NULL; + for (int i = 0; i < MDNS_MAX_SERVICES && !s; i++) { + if (g.svcs[i].used && strcmp(g.svcs[i].type, service_type) == 0 && strcmp(g.svcs[i].proto, protocol) == 0) { + s = &g.svcs[i]; + } + } + for (int i = 0; i < MDNS_MAX_SERVICES && !s; i++) { + if (!g.svcs[i].used) { + s = &g.svcs[i]; + } + } + if (s == NULL) { + pthread_mutex_unlock(&g.lock); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Out of MDNS service slots")); + } + s->used = true; + mdns_copy_str(s->type, service_type, sizeof(s->type)); + mdns_copy_str(s->proto, protocol, sizeof(s->proto)); + s->port = port; + s->txt_len = 0; + for (size_t i = 0; i < num_txt; i++) { + s->txt[s->txt_len++] = (uint8_t)txt_len[i]; + memcpy(s->txt + s->txt_len, txt[i], txt_len[i]); + s->txt_len += txt_len[i]; + } + g.announce_left = 2; + announce_locked(); + pthread_mutex_unlock(&g.lock); + wake_thread(); +} + +// --- find(): the querier --------------------------------------------------------- + +typedef struct { + char name[256]; + uint16_t type; + uint16_t port; // SRV + char target[256]; // PTR, SRV + uint8_t a[4]; // A +} rec_t; + +struct recs { + rec_t *recs; + size_t n, cap; +}; + +static const rec_t *find_rec(const recs_t *r, const char *name, uint16_t type) { + for (size_t i = 0; i < r->n; i++) { + if (r->recs[i].type == type && strcasecmp(r->recs[i].name, name) == 0) { + return &r->recs[i]; + } + } + return NULL; +} + +static void keep_rec(recs_t *r, const rec_t *rec) { + for (size_t i = 0; i < r->n; i++) { + const rec_t *o = &r->recs[i]; + if (o->type == rec->type && strcasecmp(o->name, rec->name) == 0 + && strcasecmp(o->target, rec->target) == 0 && o->port == rec->port + && memcmp(o->a, rec->a, 4) == 0) { + return; + } + } + if (r->n == 512) { + return; // enough: the rest of a busy network's chatter can go + } + if (r->n == r->cap) { + size_t cap = r->cap ? r->cap * 2 : 32; + rec_t *grown = realloc(r->recs, cap * sizeof(rec_t)); + if (grown == NULL) { + return; + } + r->recs = grown; + r->cap = cap; + } + r->recs[r->n++] = *rec; +} + +// Every A, PTR and SRV record in a message (answers, authority, additional). +static void take_records(recs_t *r, const uint8_t *msg, size_t len) { + if (len < 12 || !(msg[2] & 0x80)) { + return; + } + size_t off = 12; + char skip[256]; + for (uint16_t i = 0, qd = get16(msg + 4); i < qd; i++) { + if (!read_name(msg, len, &off, skip, sizeof(skip)) || off + 4 > len) { + return; + } + off += 4; + } + unsigned count = get16(msg + 6) + get16(msg + 8) + get16(msg + 10); + for (unsigned i = 0; i < count; i++) { + rec_t rec; + memset(&rec, 0, sizeof(rec)); + if (!read_name(msg, len, &off, rec.name, sizeof(rec.name)) || off + 10 > len) { + return; + } + rec.type = get16(msg + off); + uint16_t rdlen = get16(msg + off + 8); + off += 10; + if (off + rdlen > len) { + return; + } + size_t at = off; + bool keep = false; + if (rec.type == T_A && rdlen == 4) { + memcpy(rec.a, msg + at, 4); + keep = true; + } else if (rec.type == T_PTR) { + keep = read_name(msg, len, &at, rec.target, sizeof(rec.target)); + } else if (rec.type == T_SRV && rdlen >= 7) { + rec.port = get16(msg + at + 4); + at += 6; + keep = read_name(msg, len, &at, rec.target, sizeof(rec.target)); + } + if (keep) { + keep_rec(r, &rec); + } + off += rdlen; + } +} + +static mp_uint_t elapsed_ms(mp_uint_t start) { + return mp_hal_ticks_ms() - start; +} + +typedef struct { + int fd; // the spare port's socket, or -1 to ask from 5353 + recs_t recs; +} ask_t; + +static size_t build_query(uint8_t *pkt, size_t cap, uint16_t ident, const char (*names)[256], const uint16_t *types, size_t n) { + wr_t w = { pkt, 12, cap, false }; + memset(pkt, 0, 12); + pkt[0] = (uint8_t)(ident >> 8); + pkt[1] = (uint8_t)ident; + for (size_t i = 0; i < n; i++) { + put_dotted(&w, names[i]); + put_end(&w); + put16(&w, types[i]); + put16(&w, CLASS_IN); + } + pkt[4] = (uint8_t)(n >> 8); + pkt[5] = (uint8_t)n; + return w.overflow ? 0 : w.len; +} + +static void ask_send(ask_t *q, const uint8_t *pkt, size_t len, const struct sockaddr_in *dst) { + if (len == 0) { + return; + } + if (q->fd >= 0) { + sendto(q->fd, pkt, len, 0, (const struct sockaddr *)dst, sizeof(*dst)); + } else { + pthread_mutex_lock(&g.lock); + send_to(pkt, len, dst); + pthread_mutex_unlock(&g.lock); + } +} + +// The thread stops adding to q's records, so they can be read without the lock. +static void ask_stop(ask_t *q) { + if (q->fd < 0) { + pthread_mutex_lock(&g.lock); + g.collect = NULL; + pthread_mutex_unlock(&g.lock); + } +} + +// Send the questions, then gather answers for ms milliseconds, asking again +// halfway. Ctrl-C raises out of here; the caller closes the socket. +static void ask(ask_t *q, const char (*names)[256], const uint16_t *types, size_t n, uint16_t ident, mp_int_t ms) { + static uint8_t pkt[PKT_MAX]; + size_t len = build_query(pkt, sizeof(pkt), ident, names, types, n); + struct sockaddr_in dst; + group_addr(&dst); + if (q->fd < 0) { + pthread_mutex_lock(&g.lock); + g.collect = &q->recs; + pthread_mutex_unlock(&g.lock); + } + ask_send(q, pkt, len, &dst); + mp_uint_t start = mp_hal_ticks_ms(); + bool resent = false; + uint8_t *msg = malloc(PKT_MAX); + if (msg == NULL) { + return; + } + for (;;) { + mp_int_t left = ms - (mp_int_t)elapsed_ms(start); + if (left <= 0) { + break; + } + if (!resent && left < ms / 2) { + ask_send(q, pkt, len, &dst); + resent = true; + } + struct pollfd pfd = { .fd = q->fd, .events = POLLIN }; + int rc; + MP_THREAD_GIL_EXIT(); + rc = poll(&pfd, q->fd < 0 ? 0 : 1, left < 50 ? (int)left : 50); + MP_THREAD_GIL_ENTER(); + if (rc > 0) { + ssize_t got = recv(q->fd, msg, PKT_MAX, 0); + if (got > 0) { + take_records(&q->recs, msg, (size_t)got); + } + } + // Ctrl-C ends the search; the caller cleans up. + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + mp_handle_pending(true); + nlr_pop(); + } else { + ask_stop(q); + free(msg); + nlr_jump(nlr.ret_val); + } + } + ask_stop(q); + free(msg); +} + +static void strip_local(char *name) { + size_t n = strlen(name); + if (n > 6 && strcasecmp(name + n - 6, ".local") == 0) { + name[n - 6] = '\0'; + } +} + +size_t mdns_port_find(mdns_server_obj_t *self, const char *service_type, const char *protocol, + mp_int_t timeout_ms, mdns_found_t *out, size_t out_len) { + (void)self; + ask_t q = { .fd = -1 }; + pthread_mutex_lock(&g.lock); + bool from_5353 = g.thread_live; + pthread_mutex_unlock(&g.lock); + if (!from_5353) { + q.fd = socket(AF_INET, SOCK_DGRAM, 0); + if (q.fd < 0) { + mp_raise_OSError(errno); + } + } + uint8_t ip[4]; + pthread_mutex_lock(&g.lock); + memcpy(ip, g.ip, 4); + pthread_mutex_unlock(&g.lock); + if (q.fd >= 0) { + struct in_addr iface; + memcpy(&iface.s_addr, ip, 4); + setsockopt(q.fd, IPPROTO_IP, IP_MULTICAST_IF, &iface, sizeof(iface)); + unsigned char ttl = 255, loop = 1; + setsockopt(q.fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)); + setsockopt(q.fd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop)); + } + + char (*names)[256] = calloc(2 * MDNS_FIND_MAX + 1, 256); + uint16_t *types = calloc(2 * MDNS_FIND_MAX + 1, sizeof(uint16_t)); + char (*inst)[256] = calloc(MDNS_FIND_MAX, 256); + size_t n = 0; + nlr_buf_t nlr; + if (names == NULL || types == NULL || inst == NULL) { + goto done; + } + if (nlr_push(&nlr) == 0) { + char svc[256]; + snprintf(svc, sizeof(svc), "%s.%s.local", service_type, protocol); + // A query from 5353 is an ordinary mDNS one, whose ID is zero (RFC 6762 18.1). + uint16_t ident = q.fd < 0 ? 0 : (uint16_t)(mp_hal_ticks_ms() | 1); + mdns_copy_str(names[0], svc, 256); + types[0] = T_PTR; + ask(&q, (const char (*)[256])names, types, 1, ident, timeout_ms * 6 / 10); + + // The instances, once each, as first spelled. + size_t ni = 0; + for (size_t i = 0; i < q.recs.n && ni < out_len && ni < MDNS_FIND_MAX; i++) { + const rec_t *r = &q.recs.recs[i]; + if (r->type != T_PTR || strcasecmp(r->name, svc) != 0) { + continue; + } + bool seen = false; + for (size_t j = 0; j < ni; j++) { + seen |= strcasecmp(inst[j], r->target) == 0; + } + if (!seen) { + mdns_copy_str(inst[ni++], r->target, 256); + } + } + // Some responders send the port and address with the PTR; for the + // rest, ask for what's missing. + size_t nf = 0; + for (size_t i = 0; i < ni; i++) { + const rec_t *srv = find_rec(&q.recs, inst[i], T_SRV); + if (srv == NULL) { + mdns_copy_str(names[nf], inst[i], 256); + types[nf++] = T_SRV; + } else if (find_rec(&q.recs, srv->target, T_A) == NULL) { + mdns_copy_str(names[nf], srv->target, 256); + types[nf++] = T_A; + } + } + if (nf) { + ask(&q, (const char (*)[256])names, types, nf, ident ? ident + 1 : 0, timeout_ms * 4 / 10); + } + for (size_t i = 0; i < ni; i++) { + mdns_found_t *f = &out[n++]; + memset(f, 0, sizeof(*f)); + // The instance name is what comes before "..local". + size_t il = strlen(inst[i]), sl = strlen(svc); + char label[256]; + mdns_copy_str(label, inst[i], sizeof(label)); + if (il > sl + 1 && strcasecmp(inst[i] + il - sl, svc) == 0) { + label[il - sl - 1] = '\0'; + } + mdns_copy_str(f->instance_name, label, sizeof(f->instance_name)); + mdns_copy_str(f->service_type, service_type, sizeof(f->service_type)); + mdns_copy_str(f->protocol, protocol, sizeof(f->protocol)); + const rec_t *srv = find_rec(&q.recs, inst[i], T_SRV); + if (srv) { + f->port = srv->port; + char host[256]; + mdns_copy_str(host, srv->target, sizeof(host)); + const rec_t *a = find_rec(&q.recs, host, T_A); + if (a) { + memcpy(f->ipv4, a->a, 4); + f->has_ipv4 = true; + } + strip_local(host); + mdns_copy_str(f->hostname, host, sizeof(f->hostname)); + } + } + nlr_pop(); + } else { + if (q.fd >= 0) { + close(q.fd); + } + free(q.recs.recs); + free(names); + free(types); + free(inst); + nlr_jump(nlr.ret_val); + } +done: + if (q.fd >= 0) { + close(q.fd); + } + free(q.recs.recs); + free(names); + free(types); + free(inst); + return n; +} + +#endif // MDNS_PORT_POSIX diff --git a/modules/mdns/src/mdns_windows.c b/modules/mdns/src/mdns_windows.c new file mode 100644 index 00000000..8d0459e4 --- /dev/null +++ b/modules/mdns/src/mdns_windows.c @@ -0,0 +1,587 @@ +// mdns on Windows: the system's DNS-SD API (windns.h, Windows 10 and later). +// +// Windows' own mDNS service holds UDP 5353, so a program doesn't answer +// questions itself: DnsServiceRegister() hands a service to that service, +// which answers for it and for its host name, and DnsServiceBrowse() and +// DnsServiceResolve() ask through it. +// +// The API calls back on a thread of its own. Callbacks touch only a context +// allocated with malloc, under its critical section, never the MicroPython +// heap; the context is freed by whichever side lets go of it last. +// +// SPDX-License-Identifier: MIT + +#ifdef _WIN32 +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0A00 +#elif _WIN32_WINNT < 0x0A00 +#undef _WIN32_WINNT +#define _WIN32_WINNT 0x0A00 +#endif +#include +#include +#include +#include +#endif + +#include "mdns_port.h" + +#if MDNS_PORT_WINDOWS + +#include +#include +#include + +#include "py/mperrno.h" +#include "py/mphal.h" +#include "py/mpthread.h" +#include "py/runtime.h" + +// --- shared helpers -------------------------------------------------------------- + +static void wsa_start(void) { + static bool started = false; + if (!started) { + WSADATA d; + started = WSAStartup(MAKEWORD(2, 2), &d) == 0; + } +} + +static bool to_wide(const char *s, wchar_t *out, size_t cap) { + return MultiByteToWideChar(CP_UTF8, 0, s, -1, out, (int)cap) > 0; +} + +static void from_wide(const wchar_t *s, char *out, size_t cap) { + if (s == NULL || WideCharToMultiByte(CP_UTF8, 0, s, -1, out, (int)cap, NULL, NULL) <= 0) { + out[0] = '\0'; + } +} + +// The IPv4 address multicast leaves from (as on unix: the route to the mDNS +// group, or the default route). +static bool local_ipv4(uint8_t ip[4]) { + static const char *const via[] = { "224.0.0.251", "8.8.8.8" }; + wsa_start(); + for (size_t i = 0; i < 2; i++) { + SOCKET fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd == INVALID_SOCKET) { + return false; + } + struct sockaddr_in a; + memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons(5353); + inet_pton(AF_INET, via[i], &a.sin_addr); + int alen = sizeof(a); + bool ok = connect(fd, (struct sockaddr *)&a, sizeof(a)) == 0 + && getsockname(fd, (struct sockaddr *)&a, &alen) == 0 + && a.sin_addr.s_addr != htonl(INADDR_ANY); + closesocket(fd); + if (ok) { + memcpy(ip, &a.sin_addr.s_addr, 4); + return true; + } + } + return false; +} + +// Wait up to ms for *flag, letting Python run (and Ctrl-C raise) meanwhile. +static void wait_for(volatile LONG *flag, mp_int_t ms) { + mp_uint_t start = mp_hal_ticks_ms(); + while (!*flag && (mp_int_t)(mp_hal_ticks_ms() - start) < ms) { + MP_THREAD_GIL_EXIT(); + Sleep(10); + MP_THREAD_GIL_ENTER(); + mp_handle_pending(true); + } +} + +// --- advertising ----------------------------------------------------------------- + +typedef struct { + DNS_SERVICE_REGISTER_REQUEST req; + PDNS_SERVICE_INSTANCE inst; + volatile LONG done; + volatile DWORD status; + char type[MDNS_NAME_MAX]; + char proto[MDNS_PROTO_MAX]; + uint16_t port; + size_t num_txt; + char *txt; // num_txt NUL-terminated records, one after another +} reg_t; + +static struct { + uint8_t ip[4]; + char host[MDNS_NAME_MAX]; + char instance[MDNS_NAME_MAX]; + reg_t *regs[MDNS_MAX_SERVICES]; +} g; + +static VOID WINAPI register_done(DWORD status, PVOID ctx, PDNS_SERVICE_INSTANCE inst) { + reg_t *r = ctx; + r->status = status; + if (inst != NULL) { + DnsServiceFreeInstance(inst); + } + InterlockedExchange(&r->done, 1); +} + +static void reg_free(reg_t *r) { + if (r->inst != NULL) { + DnsServiceFreeInstance(r->inst); + } + free(r->txt); + free(r); +} + +static void withdraw(reg_t *r) { + InterlockedExchange(&r->done, 0); + if (DnsServiceDeRegister(&r->req, NULL) == DNS_REQUEST_PENDING) { + wait_for(&r->done, 2000); + } + if (r->done) { + reg_free(r); + } + // else: Windows still holds r, and calls back into it later; leave it. +} + +// Hand r to Windows' mDNS service, built from the computer's name, the +// instance name and the address; raises if Windows refuses it. +static void publish(reg_t *r) { + char name[3 * MDNS_NAME_MAX + 16], host[MDNS_NAME_MAX + 8]; + wchar_t wname[3 * MDNS_NAME_MAX + 16], whost[MDNS_NAME_MAX + 8]; + snprintf(name, sizeof(name), "%s.%s.%s.local", g.instance, r->type, r->proto); + snprintf(host, sizeof(host), "%s.local", g.host); + wchar_t *keys[MDNS_MAX_TXT_RECORDS], *values[MDNS_MAX_TXT_RECORDS]; + wchar_t wtxt[MDNS_MAX_TXT_RECORDS][260]; + const char *t = r->txt; + for (size_t i = 0; i < r->num_txt; i++) { + size_t n = strlen(t); + to_wide(t, wtxt[i], 260); + wchar_t *eq = wcschr(wtxt[i], L'='); + keys[i] = wtxt[i]; + if (eq) { + *eq = 0; + values[i] = eq + 1; + } else { + values[i] = wtxt[i] + wcslen(wtxt[i]); // "" + } + t += n + 1; + } + if (!to_wide(name, wname, MP_ARRAY_SIZE(wname)) || !to_wide(host, whost, MP_ARRAY_SIZE(whost))) { + mp_raise_ValueError(MP_ERROR_TEXT("Failed to add service")); + } + IP4_ADDRESS ip4; + memcpy(&ip4, g.ip, 4); + r->inst = DnsServiceConstructInstance(wname, whost, &ip4, NULL, r->port, 0, 0, + (DWORD)r->num_txt, (PCWSTR *)keys, (PCWSTR *)values); + if (r->inst == NULL) { + mp_raise_OSError(MP_ENOMEM); + } + memset(&r->req, 0, sizeof(r->req)); + r->req.Version = DNS_QUERY_REQUEST_VERSION1; + r->req.InterfaceIndex = 0; + r->req.pServiceInstance = r->inst; + r->req.pRegisterCompletionCallback = register_done; + r->req.pQueryContext = r; + r->req.unicastEnabled = FALSE; + InterlockedExchange(&r->done, 0); + r->status = 0; + DWORD rc = DnsServiceRegister(&r->req, NULL); + if (rc != DNS_REQUEST_PENDING) { + mp_raise_OSError(rc == ERROR_SUCCESS ? MP_EIO : (int)rc); + } + // Windows probes the name first, which takes about a second. + wait_for(&r->done, 5000); + if (r->done && r->status != ERROR_SUCCESS) { + mp_raise_OSError((int)r->status); + } +} + +// Withdraw every service and publish it again, as the instance name changed. +static void republish_all(void) { + for (size_t i = 0; i < MDNS_MAX_SERVICES; i++) { + reg_t *r = g.regs[i]; + if (r == NULL) { + continue; + } + reg_t *fresh = calloc(1, sizeof(reg_t)); + if (fresh == NULL) { + mp_raise_OSError(MP_ENOMEM); + } + *fresh = *r; + fresh->inst = NULL; + r->txt = NULL; // fresh owns it now + withdraw(r); + g.regs[i] = fresh; + publish(fresh); + } +} + +void mdns_port_check_interface(mp_obj_t network_interface) { + // None, or on a computer with more than one network the IPv4 address to + // work from. + struct in_addr a; + if (network_interface != mp_const_none + && (!mp_obj_is_str(network_interface) + || inet_pton(AF_INET, mp_obj_str_get_str(network_interface), &a) != 1)) { + mp_raise_ValueError(MP_ERROR_TEXT("network_interface must be None or an IPv4 address")); + } +} + +void mdns_port_construct(mdns_server_obj_t *self, mp_obj_t network_interface) { + uint8_t ip[4]; + wsa_start(); + if (network_interface == mp_const_none) { + if (!local_ipv4(ip)) { + mp_raise_OSError(MP_EHOSTUNREACH); + } + } else if (mp_obj_is_str(network_interface)) { + struct in_addr a; + if (inet_pton(AF_INET, mp_obj_str_get_str(network_interface), &a) != 1) { + mp_raise_ValueError(MP_ERROR_TEXT("network_interface must be None or an IPv4 address")); + } + memcpy(ip, &a.s_addr, 4); + } else { + mp_raise_ValueError(MP_ERROR_TEXT("network_interface must be None or an IPv4 address")); + } + char host[MDNS_NAME_MAX]; + DWORD n = sizeof(host); + if (!GetComputerNameExA(ComputerNameDnsHostname, host, &n) || host[0] == '\0') { + strcpy(host, "micropython"); + } + memcpy(g.ip, ip, 4); + mdns_copy_str(g.host, host, sizeof(g.host)); + mdns_copy_str(g.instance, host, sizeof(g.instance)); + memset(g.regs, 0, sizeof(g.regs)); + mdns_copy_str(self->hostname, host, sizeof(self->hostname)); +} + +void mdns_port_deinit(mdns_server_obj_t *self) { + (void)self; + for (size_t i = 0; i < MDNS_MAX_SERVICES; i++) { + if (g.regs[i] != NULL) { + reg_t *r = g.regs[i]; + g.regs[i] = NULL; + withdraw(r); + } + } +} + +void mdns_port_set_hostname(mdns_server_obj_t *self, const char *hostname) { + (void)self; + // Windows' mDNS service answers for the computer's own name only: given + // any other, it announces services whose host has no address, and + // ESP-IDF's querier then drops the whole answer, other services and all. + if (_stricmp(hostname, g.host) != 0) { + mp_raise_msg(&mp_type_NotImplementedError, + MP_ERROR_TEXT("on Windows the hostname is the computer's name")); + } +} + +void mdns_port_set_instance_name(mdns_server_obj_t *self, const char *instance_name) { + (void)self; + mdns_copy_str(g.instance, instance_name, sizeof(g.instance)); + republish_all(); +} + +void mdns_port_advertise(mdns_server_obj_t *self, bool update, const char *service_type, const char *protocol, + uint16_t port, const char **txt, const size_t *txt_len, size_t num_txt) { + (void)self; + (void)update; + reg_t *r = calloc(1, sizeof(reg_t)); + size_t total = 0; + for (size_t i = 0; i < num_txt; i++) { + total += txt_len[i] + 1; + } + char *records = malloc(total ? total : 1); + if (r == NULL || records == NULL) { + free(r); + free(records); + mp_raise_OSError(MP_ENOMEM); + } + char *p = records; + for (size_t i = 0; i < num_txt; i++) { + memcpy(p, txt[i], txt_len[i]); + p[txt_len[i]] = '\0'; + p += txt_len[i] + 1; + } + r->txt = records; + r->num_txt = num_txt; + mdns_copy_str(r->type, service_type, sizeof(r->type)); + mdns_copy_str(r->proto, protocol, sizeof(r->proto)); + r->port = port; + // A second call for the same service replaces it. + size_t slot = MDNS_MAX_SERVICES; + for (size_t i = 0; i < MDNS_MAX_SERVICES; i++) { + reg_t *o = g.regs[i]; + if (o != NULL && strcmp(o->type, service_type) == 0 && strcmp(o->proto, protocol) == 0) { + g.regs[i] = NULL; + withdraw(o); + slot = i; + break; + } + } + for (size_t i = 0; i < MDNS_MAX_SERVICES && slot == MDNS_MAX_SERVICES; i++) { + if (g.regs[i] == NULL) { + slot = i; + } + } + if (slot == MDNS_MAX_SERVICES) { + reg_free(r); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Out of MDNS service slots")); + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + publish(r); + nlr_pop(); + } else { + if (r->done) { + reg_free(r); + } + nlr_jump(nlr.ret_val); + } + g.regs[slot] = r; +} + +// --- find(): browse, then resolve ---------------------------------------------------- + +typedef struct { + char full[256]; // "..local" + char host[256]; + uint16_t port; + bool has_ipv4; + uint8_t ipv4[4]; + bool resolved; + bool resolving; + wchar_t wfull[256]; + DNS_SERVICE_RESOLVE_REQUEST req; + DNS_SERVICE_CANCEL cancel; +} hit_t; + +typedef struct { + CRITICAL_SECTION cs; + volatile LONG refs; // ours, plus one per request Windows still holds + hit_t hits[MDNS_FIND_MAX]; + size_t n; + wchar_t query[300]; + DNS_SERVICE_BROWSE_REQUEST req; + DNS_SERVICE_CANCEL cancel; +} browse_t; + +typedef struct { + browse_t *b; + size_t i; +} resolve_ctx_t; + +static void browse_release(browse_t *b) { + if (InterlockedDecrement(&b->refs) == 0) { + DeleteCriticalSection(&b->cs); + free(b); + } +} + +static hit_t *hit_for(browse_t *b, const char *full) { + for (size_t i = 0; i < b->n; i++) { + if (_stricmp(b->hits[i].full, full) == 0) { + return &b->hits[i]; + } + } + if (b->n == MDNS_FIND_MAX) { + return NULL; + } + hit_t *h = &b->hits[b->n++]; + memset(h, 0, sizeof(*h)); + mdns_copy_str(h->full, full, sizeof(h->full)); + return h; +} + +static VOID WINAPI browse_cb(DWORD status, PVOID ctx, PDNS_RECORD records) { + browse_t *b = ctx; + if (status == ERROR_SUCCESS) { + EnterCriticalSection(&b->cs); + char name[256], target[256]; + // Some responders put the port and the address in the same answer. + for (PDNS_RECORDW r = (PDNS_RECORDW)records; r != NULL; r = r->pNext) { + if (r->wType == DNS_TYPE_PTR) { + from_wide(r->Data.PTR.pNameHost, target, sizeof(target)); + if (target[0]) { + hit_for(b, target); + } + } + } + for (PDNS_RECORDW r = (PDNS_RECORDW)records; r != NULL; r = r->pNext) { + if (r->wType == DNS_TYPE_SRV) { + from_wide(r->pName, name, sizeof(name)); + for (size_t i = 0; i < b->n; i++) { + if (_stricmp(b->hits[i].full, name) == 0) { + from_wide(r->Data.SRV.pNameTarget, b->hits[i].host, sizeof(b->hits[i].host)); + b->hits[i].port = r->Data.SRV.wPort; + } + } + } + } + for (PDNS_RECORDW r = (PDNS_RECORDW)records; r != NULL; r = r->pNext) { + if (r->wType == DNS_TYPE_A) { + from_wide(r->pName, name, sizeof(name)); + for (size_t i = 0; i < b->n; i++) { + if (b->hits[i].host[0] && _stricmp(b->hits[i].host, name) == 0) { + memcpy(b->hits[i].ipv4, &r->Data.A.IpAddress, 4); + b->hits[i].has_ipv4 = true; + b->hits[i].resolved = true; + } + } + } + } + LeaveCriticalSection(&b->cs); + } + if (records != NULL) { + DnsRecordListFree(records, DnsFreeRecordList); + } + if (status != ERROR_SUCCESS) { + // The browse has ended (cancelled, or failed): Windows lets go of b. + browse_release(b); + } +} + +static VOID WINAPI resolve_cb(DWORD status, PVOID ctx, PDNS_SERVICE_INSTANCE inst) { + resolve_ctx_t *rc = ctx; + browse_t *b = rc->b; + EnterCriticalSection(&b->cs); + hit_t *h = &b->hits[rc->i]; + if (status == ERROR_SUCCESS && inst != NULL) { + from_wide(inst->pszHostName, h->host, sizeof(h->host)); + h->port = inst->wPort; + if (inst->ip4Address != NULL) { + memcpy(h->ipv4, inst->ip4Address, 4); + h->has_ipv4 = true; + } + h->resolved = true; + } + h->resolving = false; + LeaveCriticalSection(&b->cs); + if (inst != NULL) { + DnsServiceFreeInstance(inst); + } + free(rc); + browse_release(b); +} + +static void strip_suffix(char *name, const char *suffix) { + size_t n = strlen(name), s = strlen(suffix); + if (n > s && _stricmp(name + n - s, suffix) == 0) { + name[n - s] = '\0'; + } +} + +size_t mdns_port_find(mdns_server_obj_t *self, const char *service_type, const char *protocol, + mp_int_t timeout_ms, mdns_found_t *out, size_t out_len) { + (void)self; + browse_t *b = calloc(1, sizeof(browse_t)); + if (b == NULL) { + mp_raise_OSError(MP_ENOMEM); + } + InitializeCriticalSection(&b->cs); + b->refs = 1; + char svc[200]; + snprintf(svc, sizeof(svc), "%s.%s.local", service_type, protocol); + to_wide(svc, b->query, MP_ARRAY_SIZE(b->query)); + + b->req.Version = DNS_QUERY_REQUEST_VERSION1; + b->req.InterfaceIndex = 0; + b->req.QueryName = b->query; + b->req.pBrowseCallback = browse_cb; + b->req.pQueryContext = b; + InterlockedIncrement(&b->refs); + DNS_STATUS st = DnsServiceBrowse(&b->req, &b->cancel); + if (st != DNS_REQUEST_PENDING) { + browse_release(b); // Windows didn't take it + browse_release(b); + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Unable to start mDNS query")); + } + + nlr_buf_t nlr; + bool raised = false; + void *exc = NULL; + if (nlr_push(&nlr) == 0) { + // Resolve each instance as it turns up, until the time is gone. + mp_uint_t start = mp_hal_ticks_ms(); + while ((mp_int_t)(mp_hal_ticks_ms() - start) < timeout_ms) { + EnterCriticalSection(&b->cs); + for (size_t i = 0; i < b->n; i++) { + hit_t *h = &b->hits[i]; + if (h->resolved || h->resolving) { + continue; + } + resolve_ctx_t *rc = malloc(sizeof(resolve_ctx_t)); + if (rc == NULL) { + continue; + } + rc->b = b; + rc->i = i; + to_wide(h->full, h->wfull, MP_ARRAY_SIZE(h->wfull)); + memset(&h->req, 0, sizeof(h->req)); + h->req.Version = DNS_QUERY_REQUEST_VERSION1; + h->req.InterfaceIndex = 0; + h->req.QueryName = h->wfull; + h->req.pResolveCompletionCallback = resolve_cb; + h->req.pQueryContext = rc; + InterlockedIncrement(&b->refs); + if (DnsServiceResolve(&h->req, &h->cancel) == DNS_REQUEST_PENDING) { + h->resolving = true; + } else { + InterlockedDecrement(&b->refs); + free(rc); + h->resolved = true; // give up on it + } + } + LeaveCriticalSection(&b->cs); + MP_THREAD_GIL_EXIT(); + Sleep(20); + MP_THREAD_GIL_ENTER(); + mp_handle_pending(true); + } + nlr_pop(); + } else { + raised = true; + exc = nlr.ret_val; + } + + DnsServiceBrowseCancel(&b->cancel); + size_t n = 0; + EnterCriticalSection(&b->cs); + for (size_t i = 0; i < b->n; i++) { + hit_t *h = &b->hits[i]; + if (h->resolving) { + DnsServiceResolveCancel(&h->cancel); + } + if (raised || n == out_len) { + continue; + } + mdns_found_t *f = &out[n++]; + memset(f, 0, sizeof(*f)); + char label[256], suffix[210]; + mdns_copy_str(label, h->full, sizeof(label)); + snprintf(suffix, sizeof(suffix), ".%s", svc); + strip_suffix(label, suffix); + mdns_copy_str(f->instance_name, label, sizeof(f->instance_name)); + char host[256]; + mdns_copy_str(host, h->host, sizeof(host)); + strip_suffix(host, "."); + strip_suffix(host, ".local"); + mdns_copy_str(f->hostname, host, sizeof(f->hostname)); + mdns_copy_str(f->service_type, service_type, sizeof(f->service_type)); + mdns_copy_str(f->protocol, protocol, sizeof(f->protocol)); + f->port = h->port; + f->has_ipv4 = h->has_ipv4; + memcpy(f->ipv4, h->ipv4, 4); + } + LeaveCriticalSection(&b->cs); + browse_release(b); + if (raised) { + nlr_jump(exc); + } + return n; +} + +#endif // MDNS_PORT_WINDOWS diff --git a/modules/mdns/src/mod_mdns.c b/modules/mdns/src/mod_mdns.c new file mode 100644 index 00000000..d97ddaed --- /dev/null +++ b/modules/mdns/src/mod_mdns.c @@ -0,0 +1,309 @@ +// mdns for MicroPython: CircuitPython's mdns API. +// +// server = mdns.Server(wlan) # or None +// server.advertise_service(service_type="_http", protocol="_tcp", port=80, +// txt_records=["path=/"]) +// for s in server.find(service_type="_spotify-connect", protocol="_tcp"): +// print(s.instance_name, s.ipv4_address, s.port) +// +// The names, defaults, limits and errors are CircuitPython's (shared-bindings/ +// mdns and its ports' common-hal). Each port's work is in mdns_.c, +// behind mdns_port.h. +// +// SPDX-License-Identifier: MIT + +#include + +#include "py/obj.h" +#include "py/objstr.h" +#include "py/runtime.h" + +#include "mdns_port.h" + +#if MDNS_AVAILABLE + +void mdns_copy_str(char *dest, const char *src, size_t dest_len) { + size_t n = src == NULL ? 0 : strlen(src); + if (n >= dest_len) { + n = dest_len - 1; + } + if (n) { + memcpy(dest, src, n); + } + dest[n] = '\0'; +} + +// --- RemoteService ------------------------------------------------------------- + +typedef struct { + mp_obj_base_t base; + mdns_found_t found; +} mdns_remoteservice_obj_t; + +static void mdns_remoteservice_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) { + if (dest[0] != MP_OBJ_NULL) { + return; // read-only + } + mdns_remoteservice_obj_t *self = MP_OBJ_TO_PTR(self_in); + const mdns_found_t *f = &self->found; + switch (attr) { + case MP_QSTR_hostname: + dest[0] = mp_obj_new_str(f->hostname, strlen(f->hostname)); + break; + case MP_QSTR_instance_name: + dest[0] = mp_obj_new_str(f->instance_name, strlen(f->instance_name)); + break; + case MP_QSTR_service_type: + dest[0] = mp_obj_new_str(f->service_type, strlen(f->service_type)); + break; + case MP_QSTR_protocol: + dest[0] = mp_obj_new_str(f->protocol, strlen(f->protocol)); + break; + case MP_QSTR_port: + dest[0] = MP_OBJ_NEW_SMALL_INT(f->port); + break; + case MP_QSTR_ipv4_address: + // CircuitPython's is an ipaddress.IPv4Address, which MicroPython + // doesn't have; str() of either is the dotted address. + if (f->has_ipv4) { + vstr_t v; + vstr_init(&v, 16); + vstr_printf(&v, "%u.%u.%u.%u", f->ipv4[0], f->ipv4[1], f->ipv4[2], f->ipv4[3]); + dest[0] = mp_obj_new_str_from_vstr(&v); + } else { + dest[0] = mp_const_none; + } + break; + default: + break; + } +} + +static void mdns_remoteservice_print(const mp_print_t *print, mp_obj_t self_in, mp_print_kind_t kind) { + (void)kind; + mdns_remoteservice_obj_t *self = MP_OBJ_TO_PTR(self_in); + mp_printf(print, "", self->found.instance_name, + self->found.service_type, self->found.protocol, self->found.port); +} + +static MP_DEFINE_CONST_OBJ_TYPE( + mdns_remoteservice_type, + MP_QSTR_RemoteService, + MP_TYPE_FLAG_NONE, + print, mdns_remoteservice_print, + attr, mdns_remoteservice_attr + ); + +// --- Server -------------------------------------------------------------------- + +// The live Server, if any. CircuitPython allows one at a time; deinit() (or +// the finaliser, which a soft reset runs) frees the place. Not a root pointer: +// an unreachable Server is collected and its finaliser clears this. +static mdns_server_obj_t *mdns_active; + +static mp_obj_t mdns_server_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_network_interface }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_network_interface, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + // In CircuitPython's order: the interface, then whether one is live. + mdns_port_check_interface(args[ARG_network_interface].u_obj); + if (mdns_active != NULL) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("mDNS already initialized")); + } + mdns_server_obj_t *self = mp_obj_malloc_with_finaliser(mdns_server_obj_t, type); + self->hostname[0] = '\0'; + self->instance_name[0] = '\0'; + self->num_advertised = 0; + self->inited = false; + mdns_port_construct(self, args[ARG_network_interface].u_obj); + self->inited = true; + mdns_active = self; + return MP_OBJ_FROM_PTR(self); +} + +static void check_for_deinit(mdns_server_obj_t *self) { + if (!self->inited) { + // CircuitPython's raise_deinited_error(). + mp_raise_ValueError(MP_ERROR_TEXT("Object has been deinitialized and can no longer be used. Create a new object.")); + } +} + +static mp_obj_t mdns_server_deinit(mp_obj_t self_in) { + mdns_server_obj_t *self = MP_OBJ_TO_PTR(self_in); + if (!self->inited) { + return mp_const_none; + } + self->inited = false; + if (mdns_active == self) { + mdns_active = NULL; + } + mdns_port_deinit(self); + self->num_advertised = 0; + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(mdns_server_deinit_obj, mdns_server_deinit); + +static mp_obj_t mdns_server_find(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + mdns_server_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); + check_for_deinit(self); + + // Keyword-only, as CircuitPython's binding has them. + enum { ARG_service_type, ARG_protocol, ARG_timeout }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_service_type, MP_ARG_KW_ONLY | MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_protocol, MP_ARG_KW_ONLY | MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_timeout, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = MP_OBJ_NEW_SMALL_INT(1)} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + #if MICROPY_PY_BUILTINS_FLOAT + mp_float_t timeout = mp_obj_get_float(args[ARG_timeout].u_obj); + mp_int_t timeout_ms = timeout <= 0 ? 0 : (mp_int_t)(timeout * 1000); + #else + mp_int_t timeout_ms = mp_obj_get_int(args[ARG_timeout].u_obj) * 1000; + #endif + const char *service_type = mp_obj_str_get_str(args[ARG_service_type].u_obj); + const char *protocol = mp_obj_str_get_str(args[ARG_protocol].u_obj); + + mdns_found_t *found = m_new(mdns_found_t, MDNS_FIND_MAX); + memset(found, 0, sizeof(mdns_found_t) * MDNS_FIND_MAX); + size_t n = mdns_port_find(self, service_type, protocol, timeout_ms, found, MDNS_FIND_MAX); + mp_obj_tuple_t *tuple = MP_OBJ_TO_PTR(mp_obj_new_tuple(n, NULL)); + for (size_t i = 0; i < n; i++) { + mdns_remoteservice_obj_t *service = mp_obj_malloc(mdns_remoteservice_obj_t, &mdns_remoteservice_type); + service->found = found[i]; + tuple->items[i] = MP_OBJ_FROM_PTR(service); + } + m_del(mdns_found_t, found, MDNS_FIND_MAX); + return MP_OBJ_FROM_PTR(tuple); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(mdns_server_find_obj, 1, mdns_server_find); + +static mp_obj_t mdns_server_advertise_service(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + mdns_server_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); + check_for_deinit(self); + + enum { ARG_service_type, ARG_protocol, ARG_port, ARG_txt_records }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_service_type, MP_ARG_KW_ONLY | MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_protocol, MP_ARG_KW_ONLY | MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_port, MP_ARG_KW_ONLY | MP_ARG_REQUIRED | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_txt_records, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + const char *service_type = mp_obj_str_get_str(args[ARG_service_type].u_obj); + const char *protocol = mp_obj_str_get_str(args[ARG_protocol].u_obj); + mp_int_t port = args[ARG_port].u_int; + if (port < 0 || port > 65535) { + mp_raise_ValueError(MP_ERROR_TEXT("port must be 0-65535")); + } + + mp_obj_t txt_records = args[ARG_txt_records].u_obj; + size_t num_txt = txt_records == mp_const_none ? 0 : (size_t)mp_obj_get_int(mp_obj_len(txt_records)); + if (num_txt > MDNS_MAX_TXT_RECORDS) { + mp_raise_msg_varg(&mp_type_ValueError, MP_ERROR_TEXT("%q length must be <= %d"), + MP_QSTR_txt_records, MDNS_MAX_TXT_RECORDS); + } + const char *txt[MDNS_MAX_TXT_RECORDS]; + size_t txt_len[MDNS_MAX_TXT_RECORDS]; + for (size_t i = 0; i < num_txt; i++) { + mp_obj_t record = mp_obj_subscr(txt_records, MP_OBJ_NEW_SMALL_INT(i), MP_OBJ_SENTINEL); + if (!mp_obj_is_str_or_bytes(record)) { + mp_raise_ValueError(MP_ERROR_TEXT("Failed to add service TXT record; non-string or bytes found in txt_records")); + } + txt[i] = mp_obj_str_get_data(record, &txt_len[i]); + if (txt_len[i] == 0 || txt_len[i] > 255 || txt[i][0] == '=') { + mp_raise_ValueError(MP_ERROR_TEXT("Failed to add service TXT record")); + } + } + + // service_type and protocol occur on one port; a second call updates it. + bool update = false; + for (size_t i = 0; i < self->num_advertised; i++) { + if (strcmp(self->advertised[i].service_type, service_type) == 0 + && strcmp(self->advertised[i].protocol, protocol) == 0) { + update = true; + break; + } + } + if (!update && self->num_advertised == MDNS_MAX_SERVICES) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("Out of MDNS service slots")); + } + mdns_port_advertise(self, update, service_type, protocol, (uint16_t)port, txt, txt_len, num_txt); + if (!update) { + mdns_advertised_t *a = &self->advertised[self->num_advertised++]; + mdns_copy_str(a->service_type, service_type, sizeof(a->service_type)); + mdns_copy_str(a->protocol, protocol, sizeof(a->protocol)); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(mdns_server_advertise_service_obj, 1, mdns_server_advertise_service); + +static void mdns_server_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) { + mdns_server_obj_t *self = MP_OBJ_TO_PTR(self_in); + if (attr != MP_QSTR_hostname && attr != MP_QSTR_instance_name) { + dest[1] = MP_OBJ_SENTINEL; // carry on to the methods + return; + } + if (dest[0] == MP_OBJ_NULL) { + check_for_deinit(self); + const char *s = self->hostname; + if (attr == MP_QSTR_instance_name && self->instance_name[0] != '\0') { + s = self->instance_name; + } + dest[0] = mp_obj_new_str(s, strlen(s)); + } else if (dest[0] == MP_OBJ_SENTINEL && dest[1] != MP_OBJ_NULL) { + check_for_deinit(self); + const char *s = mp_obj_str_get_str(dest[1]); + if (attr == MP_QSTR_hostname) { + mdns_port_set_hostname(self, s); + mdns_copy_str(self->hostname, s, sizeof(self->hostname)); + } else { + mdns_port_set_instance_name(self, s); + mdns_copy_str(self->instance_name, s, sizeof(self->instance_name)); + } + dest[0] = MP_OBJ_NULL; // stored + } +} + +static const mp_rom_map_elem_t mdns_server_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_find), MP_ROM_PTR(&mdns_server_find_obj) }, + { MP_ROM_QSTR(MP_QSTR_advertise_service), MP_ROM_PTR(&mdns_server_advertise_service_obj) }, + { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&mdns_server_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&mdns_server_deinit_obj) }, +}; +static MP_DEFINE_CONST_DICT(mdns_server_locals_dict, mdns_server_locals_dict_table); + +static MP_DEFINE_CONST_OBJ_TYPE( + mdns_server_type, + MP_QSTR_Server, + MP_TYPE_FLAG_NONE, + make_new, mdns_server_make_new, + attr, mdns_server_attr, + locals_dict, &mdns_server_locals_dict + ); + +// --- the module ---------------------------------------------------------------- + +static const mp_rom_map_elem_t mdns_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_mdns) }, + { MP_ROM_QSTR(MP_QSTR_Server), MP_ROM_PTR(&mdns_server_type) }, + { MP_ROM_QSTR(MP_QSTR_RemoteService), MP_ROM_PTR(&mdns_remoteservice_type) }, +}; +static MP_DEFINE_CONST_DICT(mdns_module_globals, mdns_module_globals_table); + +const mp_obj_module_t mdns_user_cmodule = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&mdns_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_mdns, mdns_user_cmodule); + +#endif // MDNS_AVAILABLE diff --git a/modules/mdns/tests/check_mdns_zeroconf.py b/modules/mdns/tests/check_mdns_zeroconf.py new file mode 100644 index 00000000..87ca56e1 --- /dev/null +++ b/modules/mdns/tests/check_mdns_zeroconf.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""mdns against another implementation: python-zeroconf, in another process. + + python3 check_mdns_zeroconf.py ./builds/unix/standard/micropython + +MicroPython advertises a service and zeroconf finds it, port, address and TXT +records; then zeroconf advertises one and MicroPython's find() lists it. +Prints PASS or FAIL per check; exits 1 on a failure. Needs `pip install +zeroconf` and a network with a route for multicast. +""" + +import socket +import subprocess +import sys +import time + +from zeroconf import IPVersion, ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf + +failures = 0 + + +def check(name, ok, detail=""): + global failures + print(("PASS " if ok else "FAIL ") + name + ("" if ok or not detail else ": " + str(detail))) + failures += not ok + + +def local_ipv4(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("224.0.0.251", 5353)) + return s.getsockname()[0] + finally: + s.close() + + +def main(micropython): + zc = Zeroconf(ip_version=IPVersion.V4Only) + try: + # MicroPython advertises; zeroconf finds it. + advertise = """ +import mdns, time +s = mdns.Server(None) +s.instance_name = "from micropython" +s.advertise_service(service_type="_mpycheck", protocol="_tcp", port=8123, txt_records=["path=/", "flag"]) +print("ready") +time.sleep(10) +s.deinit() +""" + mp = subprocess.Popen([micropython, "-c", advertise], stdout=subprocess.PIPE, text=True) + check("MicroPython advertises", mp.stdout.readline().strip() == "ready") + names = [] + ServiceBrowser(zc, "_mpycheck._tcp.local.", + handlers=[lambda zeroconf, service_type, name, state_change: + state_change is ServiceStateChange.Added and names.append(name)]) + deadline = time.monotonic() + 6 + while "from micropython._mpycheck._tcp.local." not in names and time.monotonic() < deadline: + time.sleep(0.1) + check("zeroconf finds it", "from micropython._mpycheck._tcp.local." in names, names) + info = zc.get_service_info("_mpycheck._tcp.local.", "from micropython._mpycheck._tcp.local.", timeout=3000) + check("with its port", info is not None and info.port == 8123, info and info.port) + check("its address", info is not None and info.parsed_addresses(IPVersion.V4Only) != [], + info and info.parsed_addresses()) + check("and its TXT records", info is not None and info.properties == {b"path": b"/", b"flag": None}, + info and info.properties) + mp.terminate() + mp.wait() + + # zeroconf advertises; MicroPython finds it. + theirs = ServiceInfo( + "_zccheck._tcp.local.", + "from zeroconf._zccheck._tcp.local.", + addresses=[socket.inet_aton(local_ipv4())], + port=9123, + server="zccheck-host.local.", + ) + zc.register_service(theirs) + find = """ +import mdns +s = mdns.Server(None) +for r in s.find(service_type="_zccheck", protocol="_tcp", timeout=3): + print(r.instance_name, r.hostname, r.ipv4_address, r.port) +s.deinit() +""" + out = subprocess.run([micropython, "-c", find], capture_output=True, text=True, timeout=30).stdout + lines = [l.split(" ") for l in out.splitlines()] + check("MicroPython finds zeroconf's service", + any(l[:2] == ["from", "zeroconf"] and l[2] == "zccheck-host" and l[4] == "9123" for l in lines), out) + zc.unregister_service(theirs) + finally: + zc.close() + print("%d failed" % failures) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "micropython")) diff --git a/modules/mdns/tests/test_mdns.py b/modules/mdns/tests/test_mdns.py new file mode 100644 index 00000000..93bbb710 --- /dev/null +++ b/modules/mdns/tests/test_mdns.py @@ -0,0 +1,112 @@ +# mdns on a desktop MicroPython (unix or windows): the API, its errors, and a +# service this process advertises, found by this process. +# +# micropython modules/mdns/tests/test_mdns.py +# +# Prints PASS or FAIL per check and ends with a count; exits 1 on a failure. +# It needs a network with a route for multicast, but nothing else on it. + +import sys +import time + +import mdns + +failures = 0 + + +def check(name, ok, detail=""): + global failures + print(("PASS " if ok else "FAIL ") + name + (": " + str(detail) if detail and not ok else "")) + if not ok: + failures += 1 + + +def raises(exc, fn, *args, **kw): + try: + fn(*args, **kw) + except exc as e: + return str(e) + return None + + +def find_ours(server, port=None, timeout=2): + for s in server.find(service_type="_mpytest", protocol="_tcp", timeout=timeout): + if s.instance_name == INSTANCE and (port is None or s.port == port): + return s + return None + + +INSTANCE = "mdns test %d" % (time.ticks_ms() & 0xFFFF) + +check("Server and RemoteService are the module's", hasattr(mdns, "Server") and hasattr(mdns, "RemoteService")) +check("Server needs a network_interface", raises(TypeError, mdns.Server) is not None) +check("network_interface must be None or an address", + raises(ValueError, mdns.Server, 42) == "network_interface must be None or an IPv4 address") + +server = mdns.Server(None) +check("hostname is a name", isinstance(server.hostname, str) and len(server.hostname) > 0, server.hostname) +check("instance_name defaults to the hostname", server.instance_name == server.hostname) +check("one Server at a time", raises(RuntimeError, mdns.Server, None) == "mDNS already initialized") +check("find takes keywords only", raises(TypeError, server.find, "_mpytest", "_tcp") is not None) +check("advertise_service takes keywords only", raises(TypeError, server.advertise_service, "_mpytest", "_tcp", 1) is not None) +check("txt_records must be strings", + raises(ValueError, server.advertise_service, service_type="_x", protocol="_tcp", port=1, txt_records=[1]) + == "Failed to add service TXT record; non-string or bytes found in txt_records") +check("at most 32 txt_records", + raises(ValueError, server.advertise_service, service_type="_x", protocol="_tcp", port=1, + txt_records=["k%d=v" % i for i in range(33)]) == "txt_records length must be <= 32") + +server.instance_name = INSTANCE +check("instance_name can be set", server.instance_name == INSTANCE) +server.advertise_service(service_type="_mpytest", protocol="_tcp", port=8123, txt_records=["path=/", "flag"]) +t = time.ticks_ms() +found = find_ours(server) +took = time.ticks_diff(time.ticks_ms(), t) +check("find() sees the advertised service", found is not None) +check("find() returns within its timeout", took < 2600, took) +if found: + check("RemoteService has the service's fields", + (found.service_type, found.protocol, found.port) == ("_mpytest", "_tcp", 8123), + (found.service_type, found.protocol, found.port)) + check("RemoteService has the host", found.hostname.lower() == server.hostname.lower(), found.hostname) + check("ipv4_address is a dotted address", found.ipv4_address is not None and len(found.ipv4_address.split(".")) == 4, + found.ipv4_address) + check("RemoteService is read-only", raises(AttributeError, setattr, found, "port", 1) is not None) + +server.advertise_service(service_type="_mpytest", protocol="_tcp", port=8124) +check("a second call updates the port", find_ours(server, 8124) is not None) + +host = "mpytest-%d" % (time.ticks_ms() & 0xFFFF) +if sys.platform == "win32": + # Windows' mDNS service answers for the computer's own name only. + check("hostname stays the computer's on Windows", + raises(NotImplementedError, setattr, server, "hostname", host) is not None) + server.hostname = server.hostname +else: + server.hostname = host + check("hostname can be set", server.hostname == host, server.hostname) + found = find_ours(server, 8124) + check("the service moves to the new hostname", + found is not None and found.hostname.lower() == host and found.ipv4_address is not None, + found and (found.hostname, found.ipv4_address)) + +check("nothing answers for a service nobody offers", + server.find(service_type="_nobody-offers-this", protocol="_tcp", timeout=0.5) == ()) + +server.deinit() +check("deinit() twice is fine", raises(Exception, server.deinit) is None) +check("a deinited Server refuses", raises(ValueError, getattr, server, "hostname") is not None) + +again = mdns.Server(None) +check("a new Server once the old one is deinited", again.hostname) +# Windows' mDNS service can answer for a withdrawn service for a moment. +gone = False +for _ in range(4): + if find_ours(again, timeout=1) is None: + gone = True + break +check("deinit() withdrew the service", gone) +again.deinit() + +print("%d failed" % failures) +sys.exit(1 if failures else 0)