From cba823df547fa7a07bd49ecae7eb07e709dbd55d Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Thu, 8 Oct 2026 16:24:06 -0700 Subject: [PATCH 1/2] Adopt ebus-service-discovery 0.4.0 for mDNS in the examples (#100) The mdns extra now installs ebus-service-discovery[zeroconf]>=0.4.0. - utility-meter --discover uses mdns.find_broker() and BrokerEndpoint.mqtt_cfg(), and the meter advertises _ebus._tcp and _device-info._tcp with mdns.Advertiser (--no-advertise opts out). - simple-span-controller browses _secure-mqtt._tcp with mdns.browse(), accepts only the instance whose SRV target is span-.local, and connects by that host name. - README, examples/README.md and CHANGELOG updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T32pvgqCzp1qCz7K5gS9Wm --- CHANGELOG.md | 12 ++++ README.md | 4 +- examples/README.md | 6 +- examples/simple-span-controller | 86 +++++++++--------------- examples/utility-meter | 114 ++++++++++++++++++++++---------- pyproject.toml | 4 +- 6 files changed, 132 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e104d..7523a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan ## [Unreleased] +Adopts [`ebus-service-discovery`](https://github.com/electrification-bus/python-service-discovery) 0.4.0 for mDNS ([#100](https://github.com/electrification-bus/python-sdk/issues/100)). + +### Added + +- `utility-meter` advertises `_ebus._tcp` and `_device-info._tcp` (framework.md requirement 6) with the library's `Advertiser` when the `mdns` extra is installed, from the meter config's `info`, under the device id as the instance name. `--no-advertise` turns it off. + +### Changed + +- The `mdns` extra installs `ebus-service-discovery[zeroconf]>=0.4.0` in place of `zeroconf>=0.131.0`. zeroconf still comes with it, as a dependency of that package. +- `utility-meter --discover` finds the broker with `ebus_service_discovery.mdns.find_broker()`. A plain (non-TLS) broker config now also accepts `_mqtt._tcp`; a TLS config still accepts only `_secure-mqtt._tcp`. `use_tls` follows the discovered service type, and when the config names a `host`, a discovered broker with that host is preferred among several. The fallback to the config's `host` after `--discover-timeout` seconds is unchanged. +- `simple-span-controller` accepts only the `_secure-mqtt._tcp` advertisement whose SRV target is `span-.local` (it matched a substring of the target before, and also browsed `_mqtt._tcp`), and connects by that host name instead of the first IPv4 address. + ## [0.25.0] — 2026-10-08 Homie 5 compliance, phase 1: producer wire correctness ([#95](https://github.com/electrification-bus/python-sdk/issues/95), part of [#91](https://github.com/electrification-bus/python-sdk/issues/91)); and a `Controller` that paces its subscriptions so the broker no longer drops retained messages ([#97](https://github.com/electrification-bus/python-sdk/issues/97)). Line numbers refer to [homieiot/convention@7edc221](https://github.com/homieiot/convention/blob/7edc221336f1644a9f04445cbc50c5af695bb047/convention.md). diff --git a/README.md b/README.md index 47eac0a..9a29f9d 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,8 @@ src/ebus_sdk/ MQTT transport lives in the separate [`ebus-mqtt-client`](https://github.com/electrification-bus/ebus-mqtt-client) package; this SDK depends on it. +mDNS broker discovery and `_ebus._tcp` / `_device-info._tcp` advertising live in the separate [`ebus-service-discovery`](https://github.com/electrification-bus/python-service-discovery) package (`find_broker`, `Advertiser`); the examples use it through the `mdns` extra. + ### homie.py Core Homie convention implementation: @@ -405,7 +407,7 @@ See [`examples/README.md`](examples/README.md) for example scripts demonstrating Optional extras: -- `mdns` (`zeroconf`) — mDNS broker discovery, used by the SPAN Panel controller example and by `utility-meter --discover` +- `mdns` (`ebus-service-discovery[zeroconf]`): mDNS broker discovery and advertising, used by the SPAN Panel controller example, by `utility-meter --discover`, and by `utility-meter`'s `_ebus._tcp` / `_device-info._tcp` advertisement - `validation` (`jsonschema`) — `$format` JSONSchema validation for `json`-datatype properties; absent it, validation is gracefully skipped ## Releases diff --git a/examples/README.md b/examples/README.md index 7a1a17e..153d7f3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,12 +31,14 @@ Publishes a single eBus utility-meter device (`energy.ebus.device.utility-meter` **Need a broker to run against?** [`broker-quickstart`](https://github.com/electrification-bus/broker-quickstart) brings up a local eBus broker on macOS with one command (`python -m laptop.run`), including the mDNS advertisement that `--discover` (below) resolves. Its `scripts/laptop-bench.sh` runs this simulator against that broker with `--discover`, end to end; see the [laptop quickstart](https://github.com/electrification-bus/broker-quickstart/blob/main/docs/laptop-quickstart.md). -Add `--discover` to find the broker over mDNS (`_secure-mqtt._tcp`) instead of using the `host`/`port` in the broker config; the broker config still supplies the TLS material. Needs the `mdns` extra (`pip install 'ebus-sdk[mdns]'`): +Add `--discover` to find the broker over mDNS, with [`ebus-service-discovery`](https://github.com/electrification-bus/python-service-discovery), instead of using the `host`/`port` in the broker config; the broker config still supplies the TLS material and credentials. A TLS broker config accepts only `_secure-mqtt._tcp`; a plain one accepts `_secure-mqtt._tcp` or `_mqtt._tcp`. When the config also names a `host`, a discovered broker with that host is preferred, and the config's `host` is the fallback after `--discover-timeout` seconds. Needs the `mdns` extra (`pip install 'ebus-sdk[mdns]'`): ```bash ./utility-meter --config ./utility-meter-cfg.example.json --broker-config /path/to/broker-cfg.json --discover ``` +With the `mdns` extra installed, the meter also advertises `_ebus._tcp` and `_device-info._tcp` (role `device`; manufacturer, model, serial number and firmware version from the config's `info`), as the eBus framework requires of every entity. `--no-advertise` turns this off. On a host with several network interfaces, see [electrification-bus/python-service-discovery#3](https://github.com/electrification-bus/python-service-discovery/issues/3). + Set a DOE envelope at runtime. The body is a single envelope object or an array of them (the retained schedule); it is set atomically as one `json` value: ```bash @@ -61,6 +63,8 @@ Discovers eBus devices and walks them as a TREE (roots then descendants), the cu Connects to a live SPAN panel via mDNS + MQTTS and determines whether the home is islanded from the grid, watching the MID child's `grid` capability (`islanding-state` / `grid-state` / `grid-forming-entity`) plus the enclosure's `power-flows`. Targets the current parent/child data model (the legacy flat `core/dominant-power-source` is retired). +Discovery accepts only the `_secure-mqtt._tcp` advertisement whose host is `span-.local`, so another broker on the network never receives this panel's credentials, and connects by host name rather than by IP address. If the panel is not heard within 5 s, it connects to `span-.local` directly. + Requires the `mdns` extra: ```bash diff --git a/examples/simple-span-controller b/examples/simple-span-controller index 05515b3..c7f2ef1 100755 --- a/examples/simple-span-controller +++ b/examples/simple-span-controller @@ -16,7 +16,8 @@ Example: ./simple-span-controller nt-2026-c192x mypassword The controller will: -1. Discover the SPAN Panel broker via mDNS (span-{serial-number}.local) +1. Discover the SPAN Panel broker via mDNS (`_secure-mqtt._tcp` advertised by + span-{serial-number}.local), using ebus-service-discovery 2. Connect to the MQTTS broker on port 8883 3. Watch the MID child's `grid` capability (islanding-state / grid-state / grid-forming-entity) to know if the home is on- or off-grid, and the @@ -36,7 +37,8 @@ import logging import time from typing import Optional -from zeroconf import ServiceBrowser, ServiceListener, Zeroconf +from ebus_service_discovery import mdns +from ebus_service_discovery.ebus import SECURE_MQTT_SERVICE, BrokerEndpoint from ebus_sdk import Controller, DiscoveredDevice @@ -59,45 +61,21 @@ ENCLOSURE_WATCH_NODE = "power-flows" MID_WATCH_NODE = "grid" -class MdnsDiscovery(ServiceListener): - """mDNS service discovery for a SPAN Panel MQTT broker.""" +def discover_panel_broker(serial_number: str, timeout: float = 5.0) -> Optional[BrokerEndpoint]: + """The panel's MQTTS broker, matched by its host name `span-.local`. - def __init__(self, serial_number: str): - self.serial_number = serial_number - self.hostname = f"span-{serial_number}.local" - self.broker_host: Optional[str] = None - self.broker_port = 8883 # Default MQTTS port - self.discovered = False - - def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: - pass - - def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: - pass - - def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: - info = zc.get_service_info(type_, name) - if info and info.server and self.hostname in info.server.lower() and info.addresses: - import socket - - self.broker_host = socket.inet_ntoa(info.addresses[0]) - self.broker_port = info.port if info.port else 8883 - self.discovered = True - logger.info(f"Discovered SPAN Panel: {self.hostname} at {self.broker_host}:{self.broker_port}") - - def discover(self, timeout: float = 5.0) -> bool: - logger.info(f"Starting mDNS discovery for: {self.hostname}") - zeroconf = Zeroconf() - for service_type in ("_secure-mqtt._tcp.local.", "_mqtt._tcp.local."): - ServiceBrowser(zeroconf, service_type, self) - start_time = time.time() - while not self.discovered and (time.time() - start_time) < timeout: - time.sleep(0.1) - zeroconf.close() - if not self.discovered: - logger.warning(f"mDNS discovery timed out, using hostname directly: {self.hostname}") - self.broker_host = self.hostname - return True + Only an advertisement whose SRV target is this panel's host name is + accepted, so another broker on the network never receives this panel's + credentials. Returns None if the panel is not heard within `timeout`. + """ + hostname = f"span-{serial_number}.local".lower() + logger.info(f"Starting mDNS discovery for: {hostname}") + for inst in mdns.browse(SECURE_MQTT_SERVICE, timeout=timeout): + if inst.server.lower() == hostname: + endpoint = BrokerEndpoint.from_instance(inst) + logger.info(f"Discovered SPAN Panel: {inst.server} at {endpoint.host}:{endpoint.port}") + return endpoint + return None class SpanPanelMonitor: @@ -116,26 +94,24 @@ class SpanPanelMonitor: self.enclosure_id = enclosure_id self.mid_id: Optional[str] = None # discovered by device type - if broker_host is None: - logger.info("No broker host specified, attempting mDNS discovery...") - discovery = MdnsDiscovery(serial_number) - discovery.discover(timeout=5.0) - broker_host = discovery.broker_host - broker_port = discovery.broker_port - if broker_host is None: - raise RuntimeError("Failed to discover SPAN Panel broker; use --broker-host") - else: - logger.info(f"Using specified broker host: {broker_host}:{broker_port}") - - mqtt_cfg = { - "host": broker_host, - "port": broker_port, + base_cfg = { "authentication": {"type": "USER_PASS", "username": serial_number, "password": password}, "use_tls": True, "tls_insecure": tls_insecure, } if tls_ca_cert: - mqtt_cfg["tls_ca_cert"] = tls_ca_cert + base_cfg["tls_ca_cert"] = tls_ca_cert + + if broker_host is None: + logger.info("No broker host specified, attempting mDNS discovery...") + endpoint = discover_panel_broker(serial_number, timeout=5.0) + if endpoint is None: + endpoint = BrokerEndpoint(SECURE_MQTT_SERVICE, f"span-{serial_number}.local", broker_port) + logger.warning(f"mDNS discovery timed out, using hostname directly: {endpoint.host}") + else: + logger.info(f"Using specified broker host: {broker_host}:{broker_port}") + endpoint = BrokerEndpoint(SECURE_MQTT_SERVICE, broker_host, broker_port) + mqtt_cfg = endpoint.mqtt_cfg(base_cfg) logger.info(f"Connecting to MQTTS broker at {mqtt_cfg['host']}:{mqtt_cfg['port']} as {serial_number}") self.controller = Controller(mqtt_cfg=mqtt_cfg) diff --git a/examples/utility-meter b/examples/utility-meter index ab6efde..440fdf7 100755 --- a/examples/utility-meter +++ b/examples/utility-meter @@ -706,48 +706,80 @@ def start_tick_thread(meter: UtilityMeter, interval_s: float) -> threading.Event # ─── Entry point ────────────────────────────────────────────────────────────── -def _discover_broker(timeout: float, log: logging.Logger) -> Optional[tuple]: - """Discover an eBus broker over mDNS (`_secure-mqtt._tcp`). - - Returns (host, port) from the advertisement, preferring the spec `broker` - TXT record (the `.local` name the broker's cert SAN covers) and - falling back to the SRV target. Returns None on timeout. Requires the - optional `mdns` extra (zeroconf). +def _discover_broker(mqtt_cfg: Dict[str, Any], timeout: float, log: logging.Logger) -> Optional[Dict[str, Any]]: + """Discover an eBus broker over mDNS with ebus-service-discovery. + + Returns a copy of `mqtt_cfg` with the discovered broker's host, port and + `use_tls` (the host is the advertised `broker` TXT value, the name the + broker's certificate covers, else the SRV target), or None when no broker + is found within `timeout` seconds. A TLS config accepts only + `_secure-mqtt._tcp`. When the config names a `host`, a discovered broker + with that host is preferred among several. Requires the `mdns` extra. """ try: - from zeroconf import ServiceBrowser, ServiceListener, Zeroconf + from ebus_service_discovery import mdns + from ebus_service_discovery.ebus import RetrySchedule except ImportError: log.error("discovery needs the 'mdns' extra: pip install 'ebus-sdk[mdns]'") return None - service_type = "_secure-mqtt._tcp.local." - found = threading.Event() - result: Dict[str, Any] = {} + mode = "discovery-with-fallback" if mqtt_cfg.get("host") else "discovery-only" + stop = threading.Event() + timer = threading.Timer(timeout, stop.set) + timer.daemon = True + timer.start() + try: + endpoint = mdns.find_broker( + mode, + base_cfg=mqtt_cfg, + stop=stop, + # Browse back to back until `timeout`; the timer, not the + # library's attempt count, decides when to give up. + schedule=RetrySchedule(fast_attempts=sys.maxsize, fast_interval=0.0), + browse_timeout=min(3.0, timeout), + ) + finally: + timer.cancel() + if endpoint is None: + return None + return endpoint.mqtt_cfg(mqtt_cfg) + - class _Listener(ServiceListener): - def add_service(self, zc, type_, name): - info = zc.get_service_info(type_, name, timeout=3000) - if info is None: - return - broker = info.properties.get(b"broker") - result["host"] = broker.decode() if broker else info.server.rstrip(".") - result["port"] = info.port or 8883 - found.set() +def _identity_from_info(device_id: str, info: Dict[str, Any]): + """The `_ebus._tcp` / `_device-info._tcp` identity from the meter's `info` config.""" + from ebus_service_discovery.ebus import Identity + + return Identity( + device_ids=[device_id], + roles=["device"], + manufacturer=str(info.get("vendor-name") or "unknown"), + model=str(info.get("model") or "unknown"), + serial_number=str(info.get("serial-number") or device_id), + fw_version=str(info["firmware-version"]) if info.get("firmware-version") else None, + device_type=DEVICE_TYPE, + ) - def update_service(self, zc, type_, name): - self.add_service(zc, type_, name) - def remove_service(self, zc, type_, name): - pass +def _start_advertiser(device_id: str, info: Dict[str, Any], log: logging.Logger): + """Advertise `_ebus._tcp` and `_device-info._tcp` (framework.md requirement 6). - zc = Zeroconf() - ServiceBrowser(zc, service_type, _Listener()) + Returns the running `mdns.Advertiser`, or None when the `mdns` extra is not + installed or advertising fails; the meter runs either way. + """ try: - if found.wait(timeout): - return result["host"], result["port"] + from ebus_service_discovery import mdns + except ImportError: + log.warning("reason=advertiseSkipped,detail=mdns extra not installed (pip install 'ebus-sdk[mdns]')") return None - finally: - zc.close() + try: + # Named for the device: on macOS the OS already advertises a + # `_device-info._tcp` instance under the host label, the default name. + advertiser = mdns.Advertiser(_identity_from_info(device_id, info), instance_name=device_id).start() + except Exception as exc: + log.warning("reason=advertiseFailed,error=%s", exc) + return None + log.info("reason=advertising,instance=%s,server=%s", advertiser.instance_name, advertiser.server) + return advertiser def main(): @@ -766,9 +798,10 @@ def main(): parser.add_argument( "--discover", action="store_true", - help="Discover the broker over mDNS (_secure-mqtt._tcp) and use the " - "advertised host/port, overriding 'host'/'port' in the broker config. " - "The broker config still supplies the TLS material.", + help="Discover the broker over mDNS and use the advertised host/port, " + "overriding 'host'/'port' in the broker config. A TLS broker config " + "accepts only _secure-mqtt._tcp. The broker config still supplies the " + "TLS material and credentials.", ) parser.add_argument( "--discover-timeout", @@ -776,6 +809,12 @@ def main(): default=8.0, help="Seconds to wait for mDNS broker discovery (default: 8.0).", ) + parser.add_argument( + "--no-advertise", + action="store_true", + help="Do not advertise _ebus._tcp / _device-info._tcp over mDNS " + "(advertised by default when the mdns extra is installed).", + ) parser.add_argument( "--doe-port", type=int, @@ -812,9 +851,9 @@ def main(): # eBus broker-discovery flow. The broker config still supplies the TLS # material; discovery only fills in where to connect. if args.discover: - endpoint = _discover_broker(args.discover_timeout, log) - if endpoint is not None: - mqtt_cfg["host"], mqtt_cfg["port"] = endpoint + discovered = _discover_broker(mqtt_cfg, args.discover_timeout, log) + if discovered is not None: + mqtt_cfg = discovered log.info( "reason=brokerDiscovered,host=%s,port=%d", mqtt_cfg["host"], @@ -848,6 +887,7 @@ def main(): ) start_doe_endpoint(meter, args.doe_port) start_tick_thread(meter, args.tick_interval) + advertiser = None if args.no_advertise else _start_advertiser(device_id, meter_cfg.get("info", {}), log) log.info( "reason=utilityMeterReady,deviceId=%s,deviceType=%s", @@ -860,6 +900,8 @@ def main(): try: threading.Event().wait() except KeyboardInterrupt: + if advertiser is not None: + advertiser.stop() adapter.stop() raise diff --git a/pyproject.toml b/pyproject.toml index 74d14c1..92be5c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,9 @@ dependencies = [ ] [project.optional-dependencies] -mdns = ["zeroconf>=0.131.0"] +# mDNS broker discovery and _ebus._tcp advertising for the examples, through +# ebus-service-discovery (which brings zeroconf). +mdns = ["ebus-service-discovery[zeroconf]>=0.4.0"] # Optional: JSONSchema validation of `json`-datatype property `$format`. Absent # it, the SDK still runs and $format validation is gracefully skipped (with a # one-time warning). Kept optional so a constrained build (e.g. Yocto) can omit From 449b5eccf551ed1362b20e657171f6f6d1222fdb Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Thu, 8 Oct 2026 16:30:32 -0700 Subject: [PATCH 2/2] Examples: keep discovery inside its deadline and reachable by IP - simple-span-controller connects to the matched SRV target (not the TXT `broker` value), and to the panel's most-preferred advertised address when this host cannot resolve `.local` names. - utility-meter --discover browses with mdns.browse_many, rank_brokers and select_broker in a loop whose last browse is cut to the time left, so it returns by --discover-timeout. find_broker's fixed browse window overran it by up to 3 s. - .ebus-spec.json notes describe the mdns extra as it now is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T32pvgqCzp1qCz7K5gS9Wm --- .ebus-spec.json | 2 +- CHANGELOG.md | 4 ++-- examples/README.md | 2 +- examples/simple-span-controller | 23 ++++++++++++++----- examples/utility-meter | 40 +++++++++++++++++---------------- 5 files changed, 42 insertions(+), 29 deletions(-) diff --git a/.ebus-spec.json b/.ebus-spec.json index 993ac11..ce59fb3 100644 --- a/.ebus-spec.json +++ b/.ebus-spec.json @@ -36,5 +36,5 @@ "devices": { "utility-meter": "0.6" }, "registries": { "capability-types": "0.19", "device-types": "0.5" } }, - "notes": "role=library: the SDK provides the framework mechanisms (supports). Excluded framework features: broker-hosting (broker's concern, not the SDK), rest-configuration (not implemented) and mdns-discovery (not implemented in the library; the optional `mdns` extra only installs zeroconf for the examples that browse for a broker). The implements map covers the examples/utility-meter reference publisher plus the capability vocabulary the SDK itself encodes. grid/status/demand/power-quality were previously defined inline in the utility-meter data model and covered implicitly by pinning it; utility-meter 0.6 split them out into standalone catalogs, so they are now pinned directly. soc is pinned because the ebus_sdk.ha customizer maps its properties to Home Assistant metadata and examples/ha-discovery-bridge publishes a soc node; it is a capability the SDK has first-class knowledge of rather than merely transports. If the simulators migrate out of examples/ into their own repo(s), each would carry its own application lockfile and this implements map would reduce to the SDK proper." + "notes": "role=library: the SDK provides the framework mechanisms (supports). Excluded framework features: broker-hosting (broker's concern, not the SDK), rest-configuration (not implemented) and mdns-discovery (not implemented in the library; the optional `mdns` extra installs ebus-service-discovery, with which examples/utility-meter advertises `_ebus._tcp` and `_device-info._tcp` and, with `--discover`, finds its broker). The implements map covers the examples/utility-meter reference publisher plus the capability vocabulary the SDK itself encodes. grid/status/demand/power-quality were previously defined inline in the utility-meter data model and covered implicitly by pinning it; utility-meter 0.6 split them out into standalone catalogs, so they are now pinned directly. soc is pinned because the ebus_sdk.ha customizer maps its properties to Home Assistant metadata and examples/ha-discovery-bridge publishes a soc node; it is a capability the SDK has first-class knowledge of rather than merely transports. If the simulators migrate out of examples/ into their own repo(s), each would carry its own application lockfile and this implements map would reduce to the SDK proper." } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7523a7a..155578e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ Adopts [`ebus-service-discovery`](https://github.com/electrification-bus/python- ### Changed - The `mdns` extra installs `ebus-service-discovery[zeroconf]>=0.4.0` in place of `zeroconf>=0.131.0`. zeroconf still comes with it, as a dependency of that package. -- `utility-meter --discover` finds the broker with `ebus_service_discovery.mdns.find_broker()`. A plain (non-TLS) broker config now also accepts `_mqtt._tcp`; a TLS config still accepts only `_secure-mqtt._tcp`. `use_tls` follows the discovered service type, and when the config names a `host`, a discovered broker with that host is preferred among several. The fallback to the config's `host` after `--discover-timeout` seconds is unchanged. -- `simple-span-controller` accepts only the `_secure-mqtt._tcp` advertisement whose SRV target is `span-.local` (it matched a substring of the target before, and also browsed `_mqtt._tcp`), and connects by that host name instead of the first IPv4 address. +- `utility-meter --discover` finds the broker with `ebus-service-discovery` (`mdns.browse_many()`, `ebus.rank_brokers()`, `ebus.select_broker()`). A plain (non-TLS) broker config now also accepts `_mqtt._tcp`; a TLS config still accepts only `_secure-mqtt._tcp`. `use_tls` follows the discovered service type, and when the config names a `host`, a discovered broker with that host is preferred among several. The fallback to the config's `host` after `--discover-timeout` seconds is unchanged. +- `simple-span-controller` accepts only the `_secure-mqtt._tcp` advertisement whose SRV target is `span-.local` (it matched a substring of the target before, and also browsed `_mqtt._tcp`), and connects by that host name instead of the first IPv4 address. When this host cannot resolve `.local` names, it connects to the panel's most-preferred advertised address. ## [0.25.0] — 2026-10-08 diff --git a/examples/README.md b/examples/README.md index 153d7f3..a0b711a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -63,7 +63,7 @@ Discovers eBus devices and walks them as a TREE (roots then descendants), the cu Connects to a live SPAN panel via mDNS + MQTTS and determines whether the home is islanded from the grid, watching the MID child's `grid` capability (`islanding-state` / `grid-state` / `grid-forming-entity`) plus the enclosure's `power-flows`. Targets the current parent/child data model (the legacy flat `core/dominant-power-source` is retired). -Discovery accepts only the `_secure-mqtt._tcp` advertisement whose host is `span-.local`, so another broker on the network never receives this panel's credentials, and connects by host name rather than by IP address. If the panel is not heard within 5 s, it connects to `span-.local` directly. +Discovery accepts only the `_secure-mqtt._tcp` advertisement whose host is `span-.local`, so another broker on the network never receives this panel's credentials, and connects by that host name, or by the panel's advertised address when this host cannot resolve `.local` names. If the panel is not heard within 5 s, it connects to `span-.local` directly. Requires the `mdns` extra: diff --git a/examples/simple-span-controller b/examples/simple-span-controller index c7f2ef1..54045df 100755 --- a/examples/simple-span-controller +++ b/examples/simple-span-controller @@ -34,6 +34,7 @@ schema migration guide. import argparse import logging +import socket import time from typing import Optional @@ -65,16 +66,26 @@ def discover_panel_broker(serial_number: str, timeout: float = 5.0) -> Optional[ """The panel's MQTTS broker, matched by its host name `span-.local`. Only an advertisement whose SRV target is this panel's host name is - accepted, so another broker on the network never receives this panel's - credentials. Returns None if the panel is not heard within `timeout`. + accepted, and the endpoint's host is that SRV target, so another broker on + the network never receives this panel's credentials. When this host cannot + resolve `.local` names, the host is the panel's advertised address instead. + Returns None if the panel is not heard within `timeout`. """ hostname = f"span-{serial_number}.local".lower() logger.info(f"Starting mDNS discovery for: {hostname}") for inst in mdns.browse(SECURE_MQTT_SERVICE, timeout=timeout): - if inst.server.lower() == hostname: - endpoint = BrokerEndpoint.from_instance(inst) - logger.info(f"Discovered SPAN Panel: {inst.server} at {endpoint.host}:{endpoint.port}") - return endpoint + if inst.server.lower() != hostname: + continue + host = inst.server + try: + socket.getaddrinfo(host, inst.port) + except socket.gaierror: + addresses = inst.candidate_addresses() + if addresses: + host = addresses[0].address + logger.info(f"{inst.server} does not resolve on this host; connecting to {host}") + logger.info(f"Discovered SPAN Panel: {inst.server} at {host}:{inst.port}") + return BrokerEndpoint(SECURE_MQTT_SERVICE, host, inst.port) return None diff --git a/examples/utility-meter b/examples/utility-meter index 440fdf7..e431044 100755 --- a/examples/utility-meter +++ b/examples/utility-meter @@ -53,6 +53,7 @@ import logging import os import sys import threading +import time from datetime import datetime, timezone from functools import partial from http import HTTPStatus @@ -718,31 +719,32 @@ def _discover_broker(mqtt_cfg: Dict[str, Any], timeout: float, log: logging.Logg """ try: from ebus_service_discovery import mdns - from ebus_service_discovery.ebus import RetrySchedule + from ebus_service_discovery.ebus import ( + SECURE_MQTT_SERVICE, + TCP_BROKER_TYPES, + BrokerEndpoint, + rank_brokers, + select_broker, + ) except ImportError: log.error("discovery needs the 'mdns' extra: pip install 'ebus-sdk[mdns]'") return None - mode = "discovery-with-fallback" if mqtt_cfg.get("host") else "discovery-only" - stop = threading.Event() - timer = threading.Timer(timeout, stop.set) - timer.daemon = True - timer.start() + # The steps of mdns.find_broker(), driven here so that each browse ends by + # the deadline: find_broker's browse window is fixed for the whole call. + accept = (SECURE_MQTT_SERVICE,) if mqtt_cfg.get("use_tls") else TCP_BROKER_TYPES + configured = BrokerEndpoint.from_mqtt_cfg(mqtt_cfg) if mqtt_cfg.get("host") else None + deadline = time.monotonic() + timeout + zc = mdns.new_zeroconf() try: - endpoint = mdns.find_broker( - mode, - base_cfg=mqtt_cfg, - stop=stop, - # Browse back to back until `timeout`; the timer, not the - # library's attempt count, decides when to give up. - schedule=RetrySchedule(fast_attempts=sys.maxsize, fast_interval=0.0), - browse_timeout=min(3.0, timeout), - ) + while (remaining := deadline - time.monotonic()) > 0: + found = mdns.browse_many(accept, min(3.0, remaining), zc) + usable = rank_brokers((BrokerEndpoint.from_instance(i) for i in found), accept=accept) + if usable: + return select_broker("discovery-with-fallback", configured, usable).mqtt_cfg(mqtt_cfg) finally: - timer.cancel() - if endpoint is None: - return None - return endpoint.mqtt_cfg(mqtt_cfg) + zc.close() + return None def _identity_from_info(device_id: str, info: Dict[str, Any]):