ebus-service-discovery 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ebus_service_discovery-0.2.0/LICENSE +21 -0
- ebus_service_discovery-0.2.0/PKG-INFO +430 -0
- ebus_service_discovery-0.2.0/README.md +399 -0
- ebus_service_discovery-0.2.0/pyproject.toml +80 -0
- ebus_service_discovery-0.2.0/setup.cfg +4 -0
- ebus_service_discovery-0.2.0/setup.py +31 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery/__init__.py +23 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery/cli.py +647 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery/py.typed +0 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery/record.py +231 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery/record.schema.json +113 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery/resolver.py +225 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery/schema.py +33 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery.egg-info/PKG-INFO +430 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery.egg-info/SOURCES.txt +20 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery.egg-info/dependency_links.txt +1 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery.egg-info/entry_points.txt +2 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery.egg-info/requires.txt +9 -0
- ebus_service_discovery-0.2.0/src/ebus_service_discovery.egg-info/top_level.txt +1 -0
- ebus_service_discovery-0.2.0/tests/test_cli.py +324 -0
- ebus_service_discovery-0.2.0/tests/test_record.py +133 -0
- ebus_service_discovery-0.2.0/tests/test_resolver.py +184 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clark Communications Corporation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ebus-service-discovery
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: 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
|
|
5
|
+
Author: Clark Communications Corporation
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ebus.energy
|
|
8
|
+
Project-URL: Repository, https://github.com/electrification-bus/python-service-discovery
|
|
9
|
+
Project-URL: Issues, https://github.com/electrification-bus/python-service-discovery/issues
|
|
10
|
+
Keywords: mdns,dns-sd,zeroconf,service-discovery,mqtt,iot
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Networking
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: ebus-mqtt-client>=0.1.7
|
|
24
|
+
Provides-Extra: validation
|
|
25
|
+
Requires-Dist: jsonschema>=4.0; extra == "validation"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.15.0; extra == "dev"
|
|
29
|
+
Requires-Dist: jsonschema>=4.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# ebus-service-discovery
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/ebus-service-discovery/)
|
|
35
|
+
[](https://github.com/astral-sh/ruff)
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
- [Why](#why)
|
|
42
|
+
- [Install](#install)
|
|
43
|
+
- [The contract](#the-contract) — topic, record schema, tombstones, freshness
|
|
44
|
+
- [Library usage](#library-usage) — model, resolver, validation
|
|
45
|
+
- [CLI usage](#cli-usage) — `dump` / `watch` / `resolve` / `validate` / `stats` / `snapshot` / `diff`, plus `--json`
|
|
46
|
+
- [Releasing](#releasing) · [Contributing](#contributing) · [License](#license)
|
|
47
|
+
|
|
48
|
+
> **Status: alpha.** The API and the v1 contract may still shift before `1.0`.
|
|
49
|
+
|
|
50
|
+
## Why
|
|
51
|
+
|
|
52
|
+
Network advertisements are messy in ways every consumer otherwise re-solves alone:
|
|
53
|
+
|
|
54
|
+
- An advertised IPv4 can be a self-assigned APIPA (`169.254.x`) address that is unroutable, while the peer is reachable only over IPv6.
|
|
55
|
+
- The same instance can be heard on several interfaces with different addresses; reachability is per-interface (a probe must bind to the right one).
|
|
56
|
+
- Records go stale unless something expires them.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install ebus-service-discovery
|
|
64
|
+
# optional JSON-Schema validation (CLI `validate`, strict callers):
|
|
65
|
+
pip install "ebus-service-discovery[validation]"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Requires Python 3.10+. Depends on [`ebus-mqtt-client`](https://github.com/electrification-bus/ebus-mqtt-client) for MQTT transport.
|
|
69
|
+
|
|
70
|
+
## The contract
|
|
71
|
+
|
|
72
|
+
The wire contract is what a publisher and every consumer agree on. It is versioned (`v1`) and specified normatively by [`record.schema.json`](src/ebus_service_discovery/record.schema.json) (JSON Schema draft 2020-12). This section is the human-readable version.
|
|
73
|
+
|
|
74
|
+
### Topic
|
|
75
|
+
|
|
76
|
+
Each record is published as a **retained** message on:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
{base}/v1/{service_type}/{interface}/{percent_encoded_instance}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Segment | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `{base}` | Deployment topic root. Default `local/mdns/discovery` (so the full base is `local/mdns/discovery/v1`). |
|
|
85
|
+
| `{service_type}` | DNS-SD service type, e.g. `_http._tcp`. |
|
|
86
|
+
| `{interface}` | The network interface the advertisement was observed on, e.g. `eth0`. The record's addresses are the candidates reachable **via this interface**. |
|
|
87
|
+
| `{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. |
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
### Record payload
|
|
92
|
+
|
|
93
|
+
| Field | Type | Required | Meaning |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| `schema_version` | int (`1`) | yes | Contract major version. |
|
|
96
|
+
| `service_type` | string | yes | DNS-SD service type. |
|
|
97
|
+
| `instance_name` | string | yes | The unencoded DNS-SD instance label (the topic carries a percent-encoded copy). |
|
|
98
|
+
| `hostname` | string | yes | SRV target hostname, e.g. `host-1234.local`. |
|
|
99
|
+
| `interface` | string | yes | Observing interface. |
|
|
100
|
+
| `port` | int | yes | SRV port as advertised. A client may deliberately use a different port. |
|
|
101
|
+
| `addresses` | array of `{address, family}` | yes | The **current** advertised addresses on this interface. Never carried forward; may be empty transiently or contain only IPv6. |
|
|
102
|
+
| `txt` | object of string→string | yes | DNS-SD TXT key/values. |
|
|
103
|
+
| `state` | `"active"` \| `"removed"` | yes | `removed` is a tombstone (see below). |
|
|
104
|
+
| `first_seen` | RFC 3339 UTC | yes | When first observed. |
|
|
105
|
+
| `last_seen` | RFC 3339 UTC | yes | When most recently confirmed. Authoritative freshness. |
|
|
106
|
+
| `ttl_seconds` | int | no | Advertised TTL / expected refresh window. |
|
|
107
|
+
| `removed_at` | RFC 3339 UTC | iff removed | Tombstone timestamp. |
|
|
108
|
+
|
|
109
|
+
Addresses are carried **raw** — only `{address, family}`. Scope, APIPA, and link-local classification are *derived client-side* from the address value (see [the model](#the-model-record--address)) so the taxonomy can evolve without a contract change.
|
|
110
|
+
|
|
111
|
+
### Tombstones and freshness
|
|
112
|
+
|
|
113
|
+
A record is **removed** in one of two ways, and a consumer honors both:
|
|
114
|
+
|
|
115
|
+
1. **Tombstone message** — a retained record with `state: "removed"` (the full last-known fields plus `removed_at`). This is the primary mechanism; it fires on a DNS-SD goodbye, a TTL expiry, or a browse `ItemRemove`.
|
|
116
|
+
2. **Empty retained payload** — clearing the retained topic (a zero-length message) also means "gone."
|
|
117
|
+
|
|
118
|
+
**Freshness** is `age = now - last_seen`. A consumer can also age a record out itself once `age > ttl_seconds` (a backstop for a missed tombstone) via `Record.is_stale(...)`.
|
|
119
|
+
|
|
120
|
+
### Example
|
|
121
|
+
|
|
122
|
+
Active record:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"schema_version": 1,
|
|
127
|
+
"service_type": "_http._tcp",
|
|
128
|
+
"instance_name": "Example Device 42",
|
|
129
|
+
"hostname": "host-1234.local",
|
|
130
|
+
"interface": "eth0",
|
|
131
|
+
"port": 80,
|
|
132
|
+
"addresses": [
|
|
133
|
+
{ "address": "192.168.1.10", "family": "ipv4" },
|
|
134
|
+
{ "address": "2606:4700:4700::1111", "family": "ipv6" },
|
|
135
|
+
{ "address": "fe80::1", "family": "ipv6" }
|
|
136
|
+
],
|
|
137
|
+
"txt": { "model": "example-1", "id": "abc123" },
|
|
138
|
+
"state": "active",
|
|
139
|
+
"first_seen": "2026-01-01T00:00:00Z",
|
|
140
|
+
"last_seen": "2026-01-01T00:05:00Z",
|
|
141
|
+
"ttl_seconds": 120
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Tombstone (same shape, `state: "removed"` + `removed_at`).
|
|
146
|
+
|
|
147
|
+
## Library usage
|
|
148
|
+
|
|
149
|
+
### The model (`Record` / `Address`)
|
|
150
|
+
|
|
151
|
+
`Record` and `Address` are plain dataclasses that round-trip the wire form. Address classification is derived from the address value:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from ebus_service_discovery import Address, Record
|
|
155
|
+
|
|
156
|
+
rec = Record.from_json(mqtt_payload) # bytes or str
|
|
157
|
+
rec.topic() # -> the retained topic for this record
|
|
158
|
+
rec.age_seconds() # -> freshness, or None if last_seen absent
|
|
159
|
+
rec.is_stale() # -> True if age > ttl_seconds
|
|
160
|
+
rec.is_removed # -> True for a tombstone
|
|
161
|
+
|
|
162
|
+
# routable addresses first, link-local/APIPA last, loopback/unspecified excluded:
|
|
163
|
+
for a in rec.candidate_addresses():
|
|
164
|
+
print(a.address, a.family.value, a.scope.value)
|
|
165
|
+
|
|
166
|
+
a = Address.parse("169.254.1.1")
|
|
167
|
+
a.scope # AddressScope.LINK_LOCAL
|
|
168
|
+
a.is_apipa # True -> DHCPv4 failed; do not prefer this
|
|
169
|
+
a.preference # sort key: lower is tried first
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### The resolver (`ServiceResolver`)
|
|
173
|
+
|
|
174
|
+
`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.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from ebus_mqtt_client import MqttClient
|
|
178
|
+
from ebus_service_discovery import ServiceResolver
|
|
179
|
+
|
|
180
|
+
mqtt = MqttClient("my-consumer", "127.0.0.1", 1883)
|
|
181
|
+
resolver = ServiceResolver(mqtt)
|
|
182
|
+
resolver.watch("_http._tcp")
|
|
183
|
+
mqtt.start()
|
|
184
|
+
# ... let retained records arrive ...
|
|
185
|
+
|
|
186
|
+
# Resolve a specific instance (match on a TXT field), on port 443 regardless of
|
|
187
|
+
# the advertised port:
|
|
188
|
+
res = resolver.resolve(
|
|
189
|
+
"_http._tcp",
|
|
190
|
+
match=lambda r: r.txt.get("id") == "abc123",
|
|
191
|
+
port=443,
|
|
192
|
+
)
|
|
193
|
+
if res:
|
|
194
|
+
url = f"https://{res.host}:{res.port}" # host is bracketed/zone-qualified as needed
|
|
195
|
+
print(f"reachable via {res.interface}: {url}")
|
|
196
|
+
else:
|
|
197
|
+
print("no reachable endpoint")
|
|
198
|
+
|
|
199
|
+
mqtt.stop()
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**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.
|
|
203
|
+
|
|
204
|
+
Constructor options:
|
|
205
|
+
|
|
206
|
+
| Option | Default | Effect |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `base` | `local/mdns/discovery/v1` | Topic base to subscribe under. |
|
|
209
|
+
| `probe_timeout` | `5.0` | Per-candidate TCP connect timeout (seconds). |
|
|
210
|
+
| `prefer_family` | `None` | `AddressFamily.IPV4`/`IPV6` as a tie-breaker (never overrides scope). |
|
|
211
|
+
| `interface_priority` | `[]` | Ordered interface names to prefer, e.g. `["eth1", "eth0", "wlan0"]`. |
|
|
212
|
+
|
|
213
|
+
Other methods: `records(service_type=None, include_stale=True)` snapshots the current view; `prune_stale()` drops aged-out records; `ingest(record)` applies a record directly (for non-MQTT feeds or tests).
|
|
214
|
+
|
|
215
|
+
### Schema validation
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from ebus_service_discovery import load_schema, validate_record
|
|
219
|
+
|
|
220
|
+
validate_record(record_dict) # raises jsonschema.ValidationError if invalid
|
|
221
|
+
schema = load_schema() # the bundled draft 2020-12 schema as a dict
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`validate_record` requires the `validation` extra (`jsonschema`); the model itself round-trips without it.
|
|
225
|
+
|
|
226
|
+
## CLI usage
|
|
227
|
+
|
|
228
|
+
Installing the package provides the `service-discovery` command. Global options select the broker and topic base:
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
service-discovery [--host H] [--port P] [--base B] [--json] <command> ...
|
|
232
|
+
# --host MQTT broker host (default 127.0.0.1)
|
|
233
|
+
# --port MQTT broker port (default 1883)
|
|
234
|
+
# --base topic base (default local/mdns/discovery/v1)
|
|
235
|
+
# --json machine-readable output for jq (see below)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`--json` is global and precedes the subcommand (`service-discovery --json dump ...`). Every command below shows its default human-readable output; the [`--json` section](#--json--machine-readable-output) shows the machine-readable form.
|
|
239
|
+
|
|
240
|
+
### `dump` — snapshot the retained bus
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
service-discovery dump # everything
|
|
244
|
+
service-discovery dump _http._tcp # one service type
|
|
245
|
+
service-discovery dump --interface eth0 --window 3
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
_http._tcp
|
|
250
|
+
eth0
|
|
251
|
+
Example Device 42 (host-1234.local:80) age 5m [active]
|
|
252
|
+
192.168.1.10 (ipv4/private)
|
|
253
|
+
2606:4700:4700::1111 (ipv6/global)
|
|
254
|
+
fe80::1 (ipv6/link-local)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### `watch` — live add / update / remove
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
service-discovery watch _http._tcp # Ctrl-C to stop
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
20:48:17 ACTIVE _http._tcp/eth0/Example Device 42 [192.168.1.10,fe80::1]
|
|
265
|
+
20:49:02 REMOVED local/mdns/discovery/v1/_http._tcp/eth0/Example%20Device%2042
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### `resolve` — reachable endpoint for a service
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
service-discovery resolve _http._tcp --match id=abc123 --probe-port 443
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
192.168.1.10:443 via eth0 (ipv4/private) instance=Example Device 42
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Exit code is `0` when resolved, `1` when nothing is reachable.
|
|
279
|
+
|
|
280
|
+
### `validate` — check records against the schema
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
service-discovery validate --file record.json # a record or a list of records
|
|
284
|
+
service-discovery validate # validate live records off the bus
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
[0] valid
|
|
289
|
+
[1] INVALID: 'removed_at' is a required property
|
|
290
|
+
2 record(s), 1 invalid
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Exit code is non-zero if any record is invalid.
|
|
294
|
+
|
|
295
|
+
### `stats` — characterize the live bus
|
|
296
|
+
|
|
297
|
+
A one-shot summary of what is currently on the bus: totals, per-interface counts, and the service-type breakdown. `--json` emits the raw characterization dict.
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
service-discovery stats
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
discovery bus stats
|
|
305
|
+
base local/mdns/discovery/v1
|
|
306
|
+
service-types 12
|
|
307
|
+
instances 41
|
|
308
|
+
addresses 63
|
|
309
|
+
stale 0
|
|
310
|
+
size 28114 bytes
|
|
311
|
+
states active:41
|
|
312
|
+
scopes global:6, link-local:9, private:48
|
|
313
|
+
families ipv4:55, ipv6:8
|
|
314
|
+
|
|
315
|
+
per interface:
|
|
316
|
+
eth0 38 instances 58 addresses
|
|
317
|
+
wlan0 3 instances 5 addresses
|
|
318
|
+
|
|
319
|
+
by service-type:
|
|
320
|
+
_airplay._tcp 9
|
|
321
|
+
_raop._tcp 7
|
|
322
|
+
...
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### `snapshot` / `diff` — soak-test the bus over time
|
|
326
|
+
|
|
327
|
+
`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.
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
service-discovery snapshot -o mon.json # capture now
|
|
331
|
+
# ... hours or days later ...
|
|
332
|
+
service-discovery snapshot -o tue.json
|
|
333
|
+
service-discovery diff mon.json tue.json # what changed?
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```
|
|
337
|
+
same core but grew (100% retained, +4 instances, 91% overlap)
|
|
338
|
+
|
|
339
|
+
between snapshots: 1d (2026-07-16T01:00:00Z -> 2026-07-17T01:12:00Z)
|
|
340
|
+
|
|
341
|
+
OLD NEW
|
|
342
|
+
service-types 12 13 +1
|
|
343
|
+
instances 41 45 +4
|
|
344
|
+
addresses 63 70 +7
|
|
345
|
+
stale 0 1 +1
|
|
346
|
+
size (bytes) 28114 30902
|
|
347
|
+
|
|
348
|
+
overlap 91% unchanged 39 changed 2 added 4 removed 0
|
|
349
|
+
|
|
350
|
+
+ added (4):
|
|
351
|
+
_http._tcp / eth0 / New Printer
|
|
352
|
+
...
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`diff` accepts a `snapshot` file or a bare `--json dump` array on either side, and honors `--json` for machine output.
|
|
356
|
+
|
|
357
|
+
### `--json` — machine-readable output
|
|
358
|
+
|
|
359
|
+
The global `--json` flag switches every command to structured output you can pipe into [`jq`](https://jqlang.github.io/jq/). Exit codes are unchanged. The shape per command:
|
|
360
|
+
|
|
361
|
+
| Command | `--json` output |
|
|
362
|
+
|---|---|
|
|
363
|
+
| `dump` | A pretty-printed JSON **array** of records. |
|
|
364
|
+
| `watch` | **Newline-delimited** JSON (one event object per line), for streaming. |
|
|
365
|
+
| `resolve` | A single JSON **object**, or `null` when nothing is reachable. |
|
|
366
|
+
| `validate` | A JSON **array** of `{ "index", "valid", "error" }` results. |
|
|
367
|
+
|
|
368
|
+
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.)
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
# every address the bus knows, one row per (instance, address), with its scope:
|
|
372
|
+
service-discovery --json dump | jq -r '
|
|
373
|
+
.[] | .instance_name as $n | .addresses[] | "\($n)\t\(.address)\t\(.scope)"'
|
|
374
|
+
|
|
375
|
+
# only instances a resolver would consider stale:
|
|
376
|
+
service-discovery --json dump | jq '[.[] | select(.is_stale)] | map(.instance_name)'
|
|
377
|
+
|
|
378
|
+
# resolve and hand the endpoint straight to curl:
|
|
379
|
+
url=$(service-discovery --json resolve _http._tcp --match id=abc123 \
|
|
380
|
+
| jq -r 'if . then "http://\(.host):\(.port)" else empty end')
|
|
381
|
+
[ -n "$url" ] && curl -sS "$url"
|
|
382
|
+
|
|
383
|
+
# stream live changes as ndjson (Ctrl-C to stop):
|
|
384
|
+
service-discovery --json watch _http._tcp | jq -c '{ts, verb, topic}'
|
|
385
|
+
|
|
386
|
+
# CI gate: fail if any retained record is invalid
|
|
387
|
+
service-discovery --json validate | jq -e 'all(.valid)' > /dev/null
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
A `dump` element looks like:
|
|
391
|
+
|
|
392
|
+
```json
|
|
393
|
+
{
|
|
394
|
+
"schema_version": 1,
|
|
395
|
+
"service_type": "_http._tcp",
|
|
396
|
+
"instance_name": "Example Device 42",
|
|
397
|
+
"hostname": "host-1234.local",
|
|
398
|
+
"interface": "eth0",
|
|
399
|
+
"port": 80,
|
|
400
|
+
"addresses": [
|
|
401
|
+
{ "address": "192.168.1.10", "family": "ipv4", "scope": "private" },
|
|
402
|
+
{ "address": "fe80::1", "family": "ipv6", "scope": "link-local" }
|
|
403
|
+
],
|
|
404
|
+
"txt": { "id": "abc123" },
|
|
405
|
+
"state": "active",
|
|
406
|
+
"first_seen": "2026-01-01T00:00:00Z",
|
|
407
|
+
"last_seen": "2026-01-01T00:05:00Z",
|
|
408
|
+
"ttl_seconds": 120,
|
|
409
|
+
"age_seconds": 300.0,
|
|
410
|
+
"is_stale": false
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## Releasing
|
|
415
|
+
|
|
416
|
+
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:
|
|
417
|
+
|
|
418
|
+
1. Bump `__version__` in `src/ebus_service_discovery/__init__.py` (the only place).
|
|
419
|
+
2. Move the CHANGELOG's `[Unreleased]` entries under a new version heading.
|
|
420
|
+
3. Commit, then tag it `v`-prefixed to match: `git tag vX.Y.Z && git push --tags`.
|
|
421
|
+
|
|
422
|
+
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).
|
|
423
|
+
|
|
424
|
+
## Contributing
|
|
425
|
+
|
|
426
|
+
See [CONTRIBUTING.md](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`](src/ebus_service_discovery/record.schema.json) or the topic layout) affect every publisher and consumer — prefer additive changes and align in a Discussion first.
|
|
427
|
+
|
|
428
|
+
## License
|
|
429
|
+
|
|
430
|
+
[MIT License](LICENSE) — Copyright (c) 2026 Clark Communications Corporation
|