Skip to main content

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:

  1. Browse. A ServiceTypeBrowser discovers every service type on the LAN (no allowlist); a per-type ServiceBrowser reports each instance; a per-instance ServiceResolver turns 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's LOCAL result flag (parity with avahi-browse --ignore-local).
  2. Model. Build an ebus_service_discovery.Record per observation and publish it retained (state=active).
  3. 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 bounded tombstone_linger a 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 LRU max_records cap bounds the keyspace.
  4. 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.
  5. Liveness ($state). The service maintains one retained topic {base}/$state, borrowing the Homie 5 device lifecycle: init while (re)building, ready once the initial browse settles, disconnected on a clean stop, and lost as the MQTT Last Will. Consumers gate their trust on ready (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-discovery and ebus-mqtt-client (installed automatically).
  • An MQTT broker to publish to (MDNSD_MQTT_HOST).
  • For the avahi backend (the avahi extra): the D-Bus binding and PyGObject, and a running avahi-daemon. In an OS image these are usually the system python3-dbus / python3-pygobject packages 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:

  1. Bump __version__ in src/ebus_mdns_discovery/__init__.py (the only place).
  2. Move the CHANGELOG's [Unreleased] entries under a new version heading.
  3. Commit, then tag it v-prefixed to match: git tag vX.Y.Z && git push --tags (a plain git push does 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ebus_mdns_discovery-0.1.0.tar.gz (35.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ebus_mdns_discovery-0.1.0-py3-none-any.whl (26.7 kB view details)

Uploaded Python 3

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

Hashes for ebus_mdns_discovery-0.1.0.tar.gz
Algorithm Hash digest
SHA256 1a66774c636bb799f50c0b290d3e8a4ab193c2ae0b993df32138cf50dd4759f6
MD5 d2a6e4e3882108a6723e4983ea292ffd
BLAKE2b-256 d923d60f73d07c234bb7bdd6c059cd3bb6019776f01123615b7aca1294f5a483

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ebus_mdns_discovery-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for ebus_mdns_discovery-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ffe62e5a257a2c99dcc93296bf7e41feb66eae39362d6ab5ed3085375b7e05b3
MD5 5bd6b7056b7ebc5cd62cbef91586e13c
BLAKE2b-256 407aedb0c59f193fbe2d3b76eba32893bdf920280c488a077c08084a111f6bc0

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page