Browse the LAN for mDNS/DNS-SD services and publish each as a retained MQTT record on the eBus service-discovery bus (v1)
Project description
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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ebus_mdns_discovery-0.1.0.tar.gz.
File metadata
- Download URL: ebus_mdns_discovery-0.1.0.tar.gz
- Upload date:
- Size: 35.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1a66774c636bb799f50c0b290d3e8a4ab193c2ae0b993df32138cf50dd4759f6
|
|
| MD5 |
d2a6e4e3882108a6723e4983ea292ffd
|
|
| BLAKE2b-256 |
d923d60f73d07c234bb7bdd6c059cd3bb6019776f01123615b7aca1294f5a483
|
Provenance
The following attestation bundles were made for ebus_mdns_discovery-0.1.0.tar.gz:
Publisher:
publish.yml on electrification-bus/python-mdns-discovery
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ebus_mdns_discovery-0.1.0.tar.gz -
Subject digest:
1a66774c636bb799f50c0b290d3e8a4ab193c2ae0b993df32138cf50dd4759f6 - Sigstore transparency entry: 2194922302
- Sigstore integration time:
-
Permalink:
electrification-bus/python-mdns-discovery@7f6a34e345ede9a123bdedafecdf56c36fb04cdd -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/electrification-bus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7f6a34e345ede9a123bdedafecdf56c36fb04cdd -
Trigger Event:
push
-
Statement type:
File details
Details for the file ebus_mdns_discovery-0.1.0-py3-none-any.whl.
File metadata
- Download URL: ebus_mdns_discovery-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ffe62e5a257a2c99dcc93296bf7e41feb66eae39362d6ab5ed3085375b7e05b3
|
|
| MD5 |
5bd6b7056b7ebc5cd62cbef91586e13c
|
|
| BLAKE2b-256 |
407aedb0c59f193fbe2d3b76eba32893bdf920280c488a077c08084a111f6bc0
|
Provenance
The following attestation bundles were made for ebus_mdns_discovery-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on electrification-bus/python-mdns-discovery
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ebus_mdns_discovery-0.1.0-py3-none-any.whl -
Subject digest:
ffe62e5a257a2c99dcc93296bf7e41feb66eae39362d6ab5ed3085375b7e05b3 - Sigstore transparency entry: 2194922304
- Sigstore integration time:
-
Permalink:
electrification-bus/python-mdns-discovery@7f6a34e345ede9a123bdedafecdf56c36fb04cdd -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/electrification-bus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7f6a34e345ede9a123bdedafecdf56c36fb04cdd -
Trigger Event:
push
-
Statement type: