ebus-mdns-discovery
mdns-discovery browses the local network for DNS-SD/mDNS services and publishes each one it finds as a retained MQTT record on the configured broker, so any service on the LAN can look up "what is on the network, and how do I reach it" by subscribing instead of browsing itself.
It is the browse-and-publish side of discovery. The record model, its JSON Schema, the topic layout, tombstone/freshness semantics, a live-view ServiceResolver, and the service-discovery debug CLI all live in the companion library ebus-service-discovery (PyPI). That repo's README is the normative description of the wire contract; this package is only the publisher that produces those records.
Each discovered service instance becomes one retained record at {base}/{service_type}/{interface}/{percent_encoded_instance}, where {base} defaults to local/mdns/discovery/v1. Addresses are carried raw; scope/APIPA/reachability classification is derived client-side by the library, so consumers get correct IPv4/IPv6 reachability handling for free.
Discovery backends
The publisher is built around a DiscoveryBackend seam: a backend browses the LAN and produces the observations the rest of the service turns into records. avahi is the only backend today (AvahiBrowser), selected by default (MDNSD_BACKEND=avahi); its D-Bus dependencies are the optional avahi extra. The seam leaves room for a pure-Python zeroconf backend (cross-platform dev, no system daemon) as a contained addition rather than a fork. The rest of the pipeline (the record model, the bounded registry, the $state liveness) is backend-agnostic.
How the avahi backend works
Event-driven, on a GLib main loop, reacting to avahi's D-Bus signals:
- Browse. A
ServiceTypeBrowserdiscovers every service type on the LAN (no allowlist); a per-typeServiceBrowserreports each instance; a per-instanceServiceResolverturns an instance into host/addresses/port/txt. A service heard on both IPv4 and IPv6 is aggregated into one observation with both addresses. The host's own advertisements are skipped by avahi'sLOCALresult flag (parity withavahi-browse --ignore-local). - Model. Build an
ebus_service_discovery.Recordper observation and publish it retained (state=active). - Bound. Removal is avahi's authoritative
ItemRemove(a real DNS-SD goodbye or an mDNS TTL expiry): the record is tombstoned (state=removed), then after a boundedtombstone_lingera GC sweep clears its retained topic (an empty retained payload, the only thing that deletes the message from the broker) and evicts the entry. A hard LRUmax_recordscap bounds the keyspace. - Reconcile on startup. Before the browse repopulates, the service reads the broker's own retained tree under
{base}/#and clears it, so records a previous process left behind are not orphaned. - Liveness (
$state). The service maintains one retained topic{base}/$state, borrowing the Homie 5 device lifecycle:initwhile (re)building,readyonce the initial browse settles,disconnectedon a clean stop, andlostas the MQTT Last Will. Consumers gate their trust onready(ServiceResolver.bus_ready).
Install
# Linux with avahi (the panel/production target): pull in the avahi backend deps
pip install "ebus-mdns-discovery[avahi]"
# base install (any platform): the contract, registry, and MQTT wiring only
pip install ebus-mdns-discovery
The avahi backend needs the D-Bus binding and PyGObject (the avahi extra), plus a running avahi-daemon. In an OS image these are usually the system python3-dbus / python3-pygobject packages rather than the wheels.
Run
MDNSD_MQTT_HOST=127.0.0.1 mdns-discovery
Only the broker host is required; everything else has a sane default. Add --no-mqtt-please to log what would be published instead of connecting (debugging).
Configuration
Config is a single typed contract loaded with precedence defaults < optional TOML file < environment (MDNSD_*) < CLI. The runtime never reads the environment directly, which keeps the package free of any deployment-specific names: a systemd unit or container maps its own variables onto MDNSD_* in its launcher.
MQTT broker
| Env | Default | Purpose |
|---|---|---|
MDNSD_MQTT_HOST |
(required) | broker host; the service fails loud if unset |
MDNSD_MQTT_PORT |
1883 |
broker port |
MDNSD_MQTT_CLIENT_ID |
mdns_discovery |
MQTT client id |
MDNSD_MQTT_KEEPALIVE_SECONDS |
60 |
keepalive |
MDNSD_MQTT_USERNAME / MDNSD_MQTT_PASSWORD |
unset | reserved (anonymous if unset) |
MDNSD_MQTT_TLS / MDNSD_MQTT_CA / MDNSD_MQTT_CERT / MDNSD_MQTT_KEY / MDNSD_MQTT_INSECURE / MDNSD_MQTT_SERVER_NAME |
off | reserved TLS axis (surface frozen, not yet wired) |
Publishing
| Env | Default | Purpose |
|---|---|---|
MDNSD_TOPIC_BASE |
local/mdns/discovery/v1 |
retained-topic root, including the contract-version segment |
MDNSD_BACKEND |
avahi |
discovery backend (only avahi today) |
Config file
Every setting can also come from a TOML file, pointed at by MDNSD_CONFIG or --config (default /etc/mdns-discovery/config.toml). The file is optional; environment variables override it, and CLI flags override both.
[mqtt]
host = "127.0.0.1"
port = 1883
[publish]
topic_base = "local/mdns/discovery/v1"
[interfaces]
allow = ["eth0", "eth1"]
deny = ["wlan0_ap"]
glob = true
[tuning]
max_records = 512
Network interfaces in and out of scope
By default every interface avahi reports is published. To scope it, following the avahi model (deny wins, an empty allow-list means "all"):
MDNSD_ALLOW_INTERFACES=eth0,eth1 # publish only these
MDNSD_DENY_INTERFACES=wlan0_ap # publish everything except this
MDNSD_ALLOW_INTERFACES="eth*,en*" # globs (default on); exclude container churn:
MDNSD_DENY_INTERFACES="veth*,docker*,br-*"
Set MDNSD_INTERFACE_GLOB=false to require exact interface names. Names match the OS interface name (the same string used as the topic's {interface} segment).
Tuning
Every knob has a working default; override only what you need.
| Env | Default | Purpose |
|---|---|---|
MDNSD_TTL_SECONDS |
unset | optional per-record ttl; unset because freshness is bus-level via $state |
MDNSD_TOMBSTONE_LINGER_SECONDS |
900 |
how long a tombstone lingers before its retained topic is cleared |
MDNSD_MAX_RECORDS |
512 |
hard LRU cap on tracked records (bounds the keyspace) |
MDNSD_GC_SWEEP_INTERVAL_SECONDS |
60 |
max wall time between GC sweeps |
MDNSD_STARTUP_CLEAR_QUIET_SECONDS |
0.3 |
end the startup retained-tree drain once idle this long |
MDNSD_STARTUP_CLEAR_MAX_SECONDS |
5.0 |
hard cap on the startup drain |
MDNSD_BROWSE_SETTLE_QUIET_SECONDS |
2.0 |
publish $state=ready once the browse burst is idle this long |
MDNSD_BROWSE_SETTLE_MAX_SECONDS |
10.0 |
hard cap before $state=ready fires anyway |
MDNSD_STATE_REASSERT_SECONDS |
120 |
re-assert ready this long after settling (beats a late lost will) |
MDNSD_AVAHI_WATCHDOG_SECONDS |
60 |
how often to probe avahi liveness |
MDNSD_MAX_RESOLVERS |
512 |
concurrent avahi resolver LRU cap |
MDNSD_RESOLVER_EVICT_LOG_EVERY |
100 |
sample rate for the resolver-cap-evict warning |
MDNSD_LOG_LEVEL |
INFO |
log level |
Layout
| Path | Role |
|---|---|
src/ebus_mdns_discovery/service.py |
the GLib main loop, avahi reconnect handling, MQTT lifecycle, and config |
src/ebus_mdns_discovery/config.py |
the typed Config dataclass and the layered loader |
src/ebus_mdns_discovery/backend.py |
the DiscoveryBackend protocol |
src/ebus_mdns_discovery/browser.py |
the avahi D-Bus browse (AvahiBrowser) + the pure InstanceTracker address aggregation |
src/ebus_mdns_discovery/registry.py |
the memory-bounded lifecycle: active/tombstone/clear/evict, LRU cap, GC sweep |
Tests
pip install -e ".[dev]"
python -m pytest tests/ -q
The suite is offline and mock-based (fake avahi/dbus objects); the live D-Bus/GLib browse is validated on a real device.
Requirements
- Python 3.10+ (the package and its dependencies use 3.10 language features).
ebus-service-discoveryandebus-mqtt-client(installed automatically).- An MQTT broker to publish to (
MDNSD_MQTT_HOST). - For the avahi backend (the
avahiextra): the D-Bus binding and PyGObject, and a runningavahi-daemon. In an OS image these are usually the systempython3-dbus/python3-pygobjectpackages rather than the wheels.
Releases
Released versions are published to PyPI; each is tagged vX.Y.Z in this repository and described in CHANGELOG.md. The project follows Semantic Versioning.
Releasing
The version lives in exactly one place: __version__ in src/ebus_mdns_discovery/__init__.py. pyproject.toml reads it dynamically, the setup.py legacy shim reads it by regex, and the publish workflow refuses to release a tag that disagrees with it. To cut a release:
- Bump
__version__insrc/ebus_mdns_discovery/__init__.py(the only place). - Move the CHANGELOG's
[Unreleased]entries under a new version heading. - Commit, then tag it
v-prefixed to match:git tag vX.Y.Z && git push --tags(a plaingit pushdoes not trigger a release).
Pushing a v* tag runs the publish workflow, which verifies the tag equals v$__version__, builds the sdist and wheel, and publishes to PyPI via Trusted Publishing (OIDC, no stored token).
Contributing
See CONTRIBUTING.md for Discussions, Issues, and pull requests. The daemon is intentionally vendor- and product-agnostic: it models generic DNS-SD discovery, and its configuration is a typed Config that a deployment maps its own names onto rather than one this package knows about. The record model and topic layout live in ebus-service-discovery; align changes to the wire contract there.
License
MIT License — Copyright (c) 2026 Clark Communications Corporation
Release files for ebus-mdns-discovery 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ebus_mdns_discovery-0.1.0.tar.gz | 35.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ebus_mdns_discovery-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 61.7 kB
Release files / ebus_mdns_discovery-0.1.0.tar.gz
| Download URL | ebus_mdns_discovery-0.1.0.tar.gz |
|---|---|
| Size | 35.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1a66774c636bb799f50c0b290d3e8a4ab193c2ae0b993df32138cf50dd4759f6
|
|
BLAKE2b-256 checksum How to use checksums |
d923d60f73d07c234bb7bdd6c059cd3bb6019776f01123615b7aca1294f5a483
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 18, 2026.
Transparency logRelease files / ebus_mdns_discovery-0.1.0-py3-none-any.whl
| Download URL | ebus_mdns_discovery-0.1.0-py3-none-any.whl |
|---|---|
| Size | 26.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ffe62e5a257a2c99dcc93296bf7e41feb66eae39362d6ab5ed3085375b7e05b3
|
|
BLAKE2b-256 checksum How to use checksums |
407aedb0c59f193fbe2d3b76eba32893bdf920280c488a077c08084a111f6bc0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 18, 2026.
Transparency log