Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ebus-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.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-<serial>.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

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).
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<serial-number>.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-<serial-number>.local` directly.

Requires the `mdns` extra:

```bash
Expand Down
99 changes: 43 additions & 56 deletions examples/simple-span-controller
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,10 +34,12 @@ schema migration guide.

import argparse
import logging
import socket
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

Expand All @@ -59,45 +62,31 @@ ENCLOSURE_WATCH_NODE = "power-flows"
MID_WATCH_NODE = "grid"


class MdnsDiscovery(ServiceListener):
"""mDNS service discovery for a SPAN Panel MQTT broker."""

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
def discover_panel_broker(serial_number: str, timeout: float = 5.0) -> Optional[BrokerEndpoint]:
"""The panel's MQTTS broker, matched by its host name `span-<serial>.local`.

Only an advertisement whose SRV target is this panel's host name is
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:
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


class SpanPanelMonitor:
Expand All @@ -116,26 +105,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)
Expand Down
Loading
Loading