Skip to main content

Client and shared model for an mDNS/DNS-SD service-discovery bus over MQTT: subscribe to advertisements, honor freshness/tombstones, and resolve a reachable address per interface

Project description

ebus-service-discovery

PyPI version Ruff

Client and shared model for an mDNS/DNS-SD service-discovery bus over MQTT. A discovery service browses the local network and publishes each advertisement as a retained MQTT record; consumers subscribe, keep a fresh view (honoring freshness and tombstones), and resolve a target service to a reachable address per interface.

This library is the consumer side plus the shared wire contract: the record model, its JSON Schema, a live-view resolver, and a debug CLI. A publisher and any number of clients share the contract described below.

Status: alpha. The API and the v1 contract may still shift before 1.0.

Why

Network advertisements are messy in ways every consumer otherwise re-solves alone:

  • An advertised IPv4 can be a self-assigned APIPA (169.254.x) address that is unroutable, while the peer is reachable only over IPv6.
  • The same instance can be heard on several interfaces with different addresses; reachability is per-interface (a probe must bind to the right one).
  • Records go stale unless something expires them.

So the contract is deliberately honest and raw — it carries the current addresses per interface plus an explicit freshness/tombstone state — and the classification and reachability policy live here, in one place, so every consumer gets them for free instead of reinventing (often buggy) copies.

Install

pip install ebus-service-discovery
# optional JSON-Schema validation (CLI `validate`, strict callers):
pip install "ebus-service-discovery[validation]"

Requires Python 3.10+. Depends on ebus-mqtt-client for MQTT transport.

The contract

The wire contract is what a publisher and every consumer agree on. It is versioned (v1) and specified normatively by record.schema.json (JSON Schema draft 2020-12). This section is the human-readable version.

Topic

Each record is published as a retained message on:

{base}/v1/{service_type}/{interface}/{percent_encoded_instance}
Segment Meaning
{base} Deployment topic root. Default local/mdns/discovery (so the full base is local/mdns/discovery/v1).
{service_type} DNS-SD service type, e.g. _http._tcp.
{interface} The network interface the advertisement was observed on, e.g. eth0. The record's addresses are the candidates reachable via this interface.
{percent_encoded_instance} The DNS-SD service instance label, percent-encoded so an arbitrary UTF-8 label (spaces, /, unicode) is a single safe topic segment.

Keying by instance (not hostname) is deliberate: DNS-SD identity lives in the instance name, and two instances can share a host. Splitting by interface is deliberate too: the same instance heard on eth0 and wlan0 is two records with different reachability.

Record payload

Field Type Required Meaning
schema_version int (1) yes Contract major version.
service_type string yes DNS-SD service type.
instance_name string yes The unencoded DNS-SD instance label (the topic carries a percent-encoded copy).
hostname string yes SRV target hostname, e.g. host-1234.local.
interface string yes Observing interface.
port int yes SRV port as advertised. A client may deliberately use a different port.
addresses array of {address, family} yes The current advertised addresses on this interface. Never carried forward; may be empty transiently or contain only IPv6.
txt object of string→string yes DNS-SD TXT key/values.
state "active" | "removed" yes removed is a tombstone (see below).
first_seen RFC 3339 UTC yes When first observed.
last_seen RFC 3339 UTC yes When discovery last confirmed the service. Observability only, NOT a liveness signal (see bus liveness).
ttl_seconds int no Optional, usually omitted. A weak per-record hint at most; $state bus liveness is the real freshness signal.
removed_at RFC 3339 UTC iff removed Tombstone timestamp.

Addresses are carried raw — only {address, family}. Scope, APIPA, and link-local classification are derived client-side from the address value (see the model) so the taxonomy can evolve without a contract change.

Tombstones and removal

A record is removed in one of two ways, and a consumer honors both:

  1. Tombstone message — a retained record with state: "removed" (the full last-known fields plus removed_at). It fires on a DNS-SD goodbye or a browse ItemRemove.
  2. Empty retained payload — clearing the retained topic (a zero-length message) also means "gone."

last_seen / ttl_seconds / Record.is_stale() are observability only, not a liveness signal. In an event-driven publisher a stable service is confirmed once at discovery and then never re-emitted, so last_seen ages indefinitely while the service is perfectly present; is_stale() therefore means "not recently re-confirmed by discovery," NOT "gone." Whether the tree is trustworthy at all is a bus-level question, answered by $state.

Bus liveness ($state)

The publisher maintains one retained topic, {base}/v1/$state, a bare lifecycle string borrowed from the Homie 5 device lifecycle (the state-machine semantics only — this is a private topic, not a Homie device, and it is invisible to generic Homie controllers):

Value Meaning
init Publisher connected; the tree is (re)building (startup clear + browse, or a discovery-daemon reconnect). Records are transient — wait for ready.
ready The tree is published and actively maintained. Unlike Homie, ready does NOT freeze the structure: the record SET churns continuously, so keep a live subscription rather than snapshotting once at ready.
disconnected Clean shutdown; the tree is a frozen last-known snapshot.
lost The publisher's MQTT Last Will: the broker sets this on an ungraceful disconnect. The tree is abandoned.

A consumer gates its trust on ready via ServiceResolver.bus_ready. While not ready, the retained records are either rebuilding or an unmaintained snapshot; a dead publisher sends no clears, so the resolver naturally keeps the last-known records, and bus_ready == False is the signal not to trust them. A crash-restart can briefly overwrite a live ready with a late will (it fires ~1.5x the MQTT keepalive after the old socket dies), so a consumer should debounce lost rather than reacting to it instantly.

Example

Active record:

{
  "schema_version": 1,
  "service_type": "_http._tcp",
  "instance_name": "Example Device 42",
  "hostname": "host-1234.local",
  "interface": "eth0",
  "port": 80,
  "addresses": [
    { "address": "192.168.1.10", "family": "ipv4" },
    { "address": "2606:4700:4700::1111", "family": "ipv6" },
    { "address": "fe80::1", "family": "ipv6" }
  ],
  "txt": { "model": "example-1", "id": "abc123" },
  "state": "active",
  "first_seen": "2026-01-01T00:00:00Z",
  "last_seen": "2026-01-01T00:05:00Z",
  "ttl_seconds": 120
}

Tombstone (same shape, state: "removed" + removed_at).

Library usage

The model (Record / Address)

Record and Address are plain dataclasses that round-trip the wire form. Address classification is derived from the address value:

from ebus_service_discovery import Address, Record

rec = Record.from_json(mqtt_payload)      # bytes or str
rec.topic()                                # -> the retained topic for this record
rec.age_seconds()                          # -> freshness, or None if last_seen absent
rec.is_stale()                             # -> True if age > ttl_seconds
rec.is_removed                             # -> True for a tombstone

# routable addresses first, link-local/APIPA last, loopback/unspecified excluded:
for a in rec.candidate_addresses():
    print(a.address, a.family.value, a.scope.value)

a = Address.parse("169.254.1.1")
a.scope          # AddressScope.LINK_LOCAL
a.is_apipa       # True  -> DHCPv4 failed; do not prefer this
a.preference     # sort key: lower is tried first

The resolver (ServiceResolver)

ServiceResolver keeps a live, tombstone-aware view of the bus and resolves a target service to a reachable endpoint. You own the MQTT connection lifecycle; the resolver only subscribes.

from ebus_mqtt_client import MqttClient
from ebus_service_discovery import ServiceResolver

mqtt = MqttClient("my-consumer", "127.0.0.1", 1883)
resolver = ServiceResolver(mqtt)
resolver.watch("_http._tcp")
mqtt.start()
# ... let retained records arrive ...

# Trust the bus only while the publisher is live (see "Bus liveness" above):
if not resolver.bus_ready:
    print("bus not ready (publisher init/lost); use last-known cautiously")

# Resolve a specific instance (match on a TXT field), on port 443 regardless of
# the advertised port:
res = resolver.resolve(
    "_http._tcp",
    match=lambda r: r.txt.get("id") == "abc123",
    port=443,
)
if res:
    url = f"https://{res.host}:{res.port}"   # host is bracketed/zone-qualified as needed
    print(f"reachable via {res.interface}: {url}")
else:
    print("no reachable endpoint")

mqtt.stop()

How resolve() chooses: it gathers every candidate (record, address) for the matching instances, orders them routable-first (by address scope), then by interface priority, then by preferred family, and TCP-probes each in order — binding the probe to the record's interface (SO_BINDTODEVICE) — returning the first that connects. Because it probes, an unreachable IPv4 (an APIPA lease, a dead interface) is simply skipped in favor of a working IPv6; there is no family-specific special-casing.

Constructor options:

Option Default Effect
base local/mdns/discovery/v1 Topic base to subscribe under.
probe_timeout 5.0 Per-candidate TCP connect timeout (seconds).
prefer_family None AddressFamily.IPV4/IPV6 as a tie-breaker (never overrides scope).
interface_priority [] Ordered interface names to prefer, e.g. ["eth1", "eth0", "wlan0"].

Other members: records(service_type=None) snapshots the current view; publisher_state / bus_ready expose the bus $state liveness (gate your trust on bus_ready); ingest(record) applies a record directly (for non-MQTT feeds or tests).

Schema validation

from ebus_service_discovery import load_schema, validate_record

validate_record(record_dict)   # raises jsonschema.ValidationError if invalid
schema = load_schema()         # the bundled draft 2020-12 schema as a dict

validate_record requires the validation extra (jsonschema); the model itself round-trips without it.

CLI usage

Installing the package provides the service-discovery command. Global options select the broker and topic base:

service-discovery [--host H] [--port P] [--base B] [--json] <command> ...
#   --host  MQTT broker host   (default 127.0.0.1)
#   --port  MQTT broker port   (default 1883)
#   --base  topic base         (default local/mdns/discovery/v1)
#   --json  machine-readable output for jq (see below)

--json is global and precedes the subcommand (service-discovery --json dump ...). Every command below shows its default human-readable output; the --json section shows the machine-readable form.

dump — snapshot the retained bus

service-discovery dump                 # everything
service-discovery dump _http._tcp      # one service type
service-discovery dump --interface eth0 --window 3
_http._tcp
  eth0
    Example Device 42  (host-1234.local:80)  age 5m  [active]
      192.168.1.10  (ipv4/private)
      2606:4700:4700::1111  (ipv6/global)
      fe80::1  (ipv6/link-local)

watch — live add / update / remove

service-discovery watch _http._tcp     # Ctrl-C to stop
20:48:17 ACTIVE   _http._tcp/eth0/Example Device 42  [192.168.1.10,fe80::1]
20:49:02 REMOVED  local/mdns/discovery/v1/_http._tcp/eth0/Example%20Device%2042

resolve — reachable endpoint for a service

service-discovery resolve _http._tcp --match id=abc123 --probe-port 443
192.168.1.10:443  via eth0  (ipv4/private)  instance=Example Device 42

Exit code is 0 when resolved, 1 when nothing is reachable.

validate — check records against the schema

service-discovery validate --file record.json     # a record or a list of records
service-discovery validate                        # validate live records off the bus
[0] valid
[1] INVALID: 'removed_at' is a required property
2 record(s), 1 invalid

Exit code is non-zero if any record is invalid.

stats — characterize the live bus

A one-shot summary of what is currently on the bus: the publisher $state, totals, per-interface counts, and the service-type breakdown. --json emits the raw characterization dict with a publisher_state field.

service-discovery stats
discovery bus stats
  base           local/mdns/discovery/v1
  publisher      ready
  service-types  12
  instances      41
  addresses      63
  stale          0
  size           28114 bytes
  states         active:41
  scopes         global:6, link-local:9, private:48
  families       ipv4:55, ipv6:8

  per interface:
    eth0           38 instances     58 addresses
    wlan0           3 instances      5 addresses

  by service-type:
    _airplay._tcp                     9
    _raop._tcp                        7
    ...

snapshot / diff — soak-test the bus over time

snapshot captures the bus plus metadata (captured_at, base) to a JSON file; diff fuzzy-compares two snapshots. The diff deliberately ignores the volatile timestamps and leads with a plain-language "is this network kinda the same?" verdict, so it is meant for "roughly the same shape?" checks, not exact matches.

service-discovery snapshot -o mon.json          # capture now
# ... hours or days later ...
service-discovery snapshot -o tue.json
service-discovery diff mon.json tue.json        # what changed?
same core but grew (100% retained, +4 instances, 91% overlap)

between snapshots: 1d  (2026-07-16T01:00:00Z -> 2026-07-17T01:12:00Z)

                       OLD       NEW
  service-types         12        13   +1
  instances             41        45   +4
  addresses             63        70   +7
  stale                  0         1   +1
  size (bytes)       28114     30902

  overlap 91%   unchanged 39   changed 2   added 4   removed 0

  + added (4):
      _http._tcp / eth0 / New Printer
      ...

diff accepts a snapshot file or a bare --json dump array on either side, and honors --json for machine output.

--json — machine-readable output

The global --json flag switches every command to structured output you can pipe into jq. Exit codes are unchanged. The shape per command:

Command --json output
dump A pretty-printed JSON array of records.
watch Newline-delimited JSON (one event object per line), for streaming.
resolve A single JSON object, or null when nothing is reachable.
validate A JSON array of { "index", "valid", "error" } results.

Each dump/watch record is the wire record enriched for debugging: the schema fields plus a derived scope on every address, plus record-level age_seconds and is_stale. (This debug shape is a superset of the wire contract; do not treat the extra keys as part of it.)

# every address the bus knows, one row per (instance, address), with its scope:
service-discovery --json dump | jq -r '
  .[] | .instance_name as $n | .addresses[] | "\($n)\t\(.address)\t\(.scope)"'

# only instances a resolver would consider stale:
service-discovery --json dump | jq '[.[] | select(.is_stale)] | map(.instance_name)'

# resolve and hand the endpoint straight to curl:
url=$(service-discovery --json resolve _http._tcp --match id=abc123 \
       | jq -r 'if . then "http://\(.host):\(.port)" else empty end')
[ -n "$url" ] && curl -sS "$url"

# stream live changes as ndjson (Ctrl-C to stop):
service-discovery --json watch _http._tcp | jq -c '{ts, verb, topic}'

# CI gate: fail if any retained record is invalid
service-discovery --json validate | jq -e 'all(.valid)' > /dev/null

A dump element looks like:

{
  "schema_version": 1,
  "service_type": "_http._tcp",
  "instance_name": "Example Device 42",
  "hostname": "host-1234.local",
  "interface": "eth0",
  "port": 80,
  "addresses": [
    { "address": "192.168.1.10", "family": "ipv4", "scope": "private" },
    { "address": "fe80::1", "family": "ipv6", "scope": "link-local" }
  ],
  "txt": { "id": "abc123" },
  "state": "active",
  "first_seen": "2026-01-01T00:00:00Z",
  "last_seen": "2026-01-01T00:05:00Z",
  "ttl_seconds": 120,
  "age_seconds": 300.0,
  "is_stale": false
}

Releasing

The version lives in exactly one place: __version__ in src/ebus_service_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_service_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.

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 library is intentionally vendor- and product-agnostic: it models generic DNS-SD discovery, not any particular device. Changes to the wire contract (record.schema.json or the topic layout) affect every publisher and consumer — prefer additive changes and align in a Discussion first.

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_service_discovery-0.3.0.tar.gz (39.2 kB view details)

Uploaded Source

Built Distribution

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

ebus_service_discovery-0.3.0-py3-none-any.whl (28.0 kB view details)

Uploaded Python 3

File details

Details for the file ebus_service_discovery-0.3.0.tar.gz.

File metadata

  • Download URL: ebus_service_discovery-0.3.0.tar.gz
  • Upload date:
  • Size: 39.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for ebus_service_discovery-0.3.0.tar.gz
Algorithm Hash digest
SHA256 f909fcbc8e5f52b1b8cda5934193f7e646bc8a1dec3f17e7b3de16960b096125
MD5 f5de0865a712a10c47ba07ccaf29d530
BLAKE2b-256 4c270acfa6fb2380f91f380477a3027142cecd2ace612e9bfaef6dda3e6ca91d

See more details on using hashes here.

Provenance

The following attestation bundles were made for ebus_service_discovery-0.3.0.tar.gz:

Publisher: publish.yml on electrification-bus/python-service-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_service_discovery-0.3.0-py3-none-any.whl.

File metadata

File hashes

Hashes for ebus_service_discovery-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c590461b568e521785f8a11ae6d2f17755e113e763144600d99388ced516d481
MD5 931d486147fdb4d89656bd30837f60f7
BLAKE2b-256 7b1c63f6892465aa7aefba0d557a5c255dba7dae541bd6b5e632d945278fba68

See more details on using hashes here.

Provenance

The following attestation bundles were made for ebus_service_discovery-0.3.0-py3-none-any.whl:

Publisher: publish.yml on electrification-bus/python-service-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