Rust SDK for Electrification Bus (eBus): Homie 5 devices and controllers over MQTT, with mDNS broker discovery. It targets Linux single-board computers first and microcontrollers later.
Status: pre-alpha. The controller and device roles are implemented, and mDNS broker discovery and host advertisement run on the Avahi and mdns-sd backends.
Coming from python-sdk? doc/python-sdk.md maps its patterns to rust-sdk.
A controller watches the devices on a broker and sends them commands. It:
- discovers devices from their Homie topics on the broker: every device in the
ebusdomain, the devices of one tree (a root and its children), or a single device; - applies each device's
$descriptionand checks every value against its property's datatype and format; - tracks each device's
$stateand its effective state (a child of alostroot islost); - reports when a device has received all its retained values (
DeviceSynced), when its values do not arrive (DeviceStuck), and when it goes quiet after a reconnect (DeviceAbsent); - re-syncs after a reconnect and clears values the broker no longer retains;
- sends
/setand$broadcast, checking a/setpayload against the property first; - emits nothing when nothing changed: a repeated value, state or description produces no event and no snapshot.
The application reads the latest state as cheap snapshots and receives changes as events.
A device publishes a tree of Homie devices (a root and its children) and takes commands for them. It:
- builds each device's
$descriptionfrom typed properties (property::<f64>,settable::<bool>,event::<i64>) and publishesinit, the description, the values andready, the root's$statelast; - publishes a value only when it changes, and never publishes events while disconnected;
- delivers each valid
/setto the application, which accepts it, accepts it with the value the device reached, or rejects it;$targetproperties report progress toward the target; - republishes the whole tree after a reconnect, and installs the root's
loststate as the connection's last will; - raises and clears alerts, changes the tree while running, and shuts down by announcing
disconnected, by letting the will fire, or by removing every topic.
A device and a controller can share one connection (ebus_rumqttc::pair).
A typical site has one MQTT broker, and every eBus host at the site connects to it. A host finds it in one of three ways:
- discovers the brokers advertised over mDNS (DNS-SD) and picks one;
- connects to a configured broker;
- uses mDNS to find a particular broker, by its host, its instance name, or a device id any of its broker advertisements carries.
The client sets which broker transports it accepts and its preference among them, by default the specification's order with _secure-mqtt._tcp first.
The host also advertises its own eBus services over mDNS. ebus-discovery-tokio finds the broker and advertises the host through avahi-daemon over D-Bus on Linux (its avahi feature), or on the pure-Rust mdns-sd backend (its mdns-sd feature) for Linux without Avahi and for macOS.
The crates are on crates.io. Most programs need only ebus-rumqttc, which re-exports the others it uses:
cargo add ebus-rumqttc
cargo add tokio --features macros,rt-multi-threadWatch every device on a broker:
use ebus_rumqttc::ebus_homie_tokio::ebus_homie::controller::{Config, Event};
use ebus_rumqttc::ebus_homie_tokio::{ControllerOptions, Delivered};
use ebus_rumqttc::rumqttc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut mqtt = rumqttc::MqttOptions::new(ebus_rumqttc::controller_client_id(), "localhost", 1883);
// A `$description` can exceed rumqttc's 10 KiB default packet size.
mqtt.set_max_packet_size(4 << 20, 4 << 20);
let run = ebus_rumqttc::controller(mqtt, Config::wildcard(), ControllerOptions::default())?;
tokio::spawn(run.task.run());
let mut events = run.events;
while let Some(delivered) = events.recv().await {
if let Delivered::Event(Event::DeviceSynced { device, .. }) = delivered {
if let Some(view) = run.handle.device(&device) {
for (node, property, p) in view.properties() {
let value = p.value.as_ref().map(|v| &*v.raw);
println!("{device}/{node}/{property} = {value:?}");
}
}
}
}
Ok(())
}Find the broker over mDNS instead, and find it again if its host gets a new address (ebus-discovery-tokio with its mdns-sd or avahi feature):
use std::time::Duration;
use ebus_discovery_tokio::ebus_discovery::BrokerConfig;
use ebus_discovery_tokio::{Mdns, find_broker, reresolve};
use ebus_rumqttc::ebus_homie_tokio::ControllerOptions;
use ebus_rumqttc::ebus_homie_tokio::ebus_homie::controller::Config;
use ebus_rumqttc::rumqttc::MqttOptions;
let mdns = Mdns::system().await?;
let cfg = BrokerConfig::default(); // discovery-only: the first broker advertised
let broker = find_broker(&mdns, cfg.clone()).await?;
let id = ebus_rumqttc::controller_client_id();
let options = |id: &str, host: &str, port: u16| {
let mut mqtt = MqttOptions::new(id, host, port);
mqtt.set_max_packet_size(4 << 20, 4 << 20);
mqtt
};
let mqtt = options(&id, &broker.connect_host(), broker.port);
let run = ebus_rumqttc::controller(mqtt, Config::wildcard(), ControllerOptions::default())?;
let task = run.task.reresolve(Box::new(move || {
let (mdns, cfg, broker, id) = (mdns.clone(), cfg.clone(), broker.clone(), id.clone());
Box::pin(async move {
let found = reresolve(&mdns, &broker, &cfg, Duration::from_secs(3)).await.ok()??;
Some(options(&id, &found.connect_host(), found.port))
})
}));
tokio::spawn(task.run());The watch example prints every controller event. With neither EBUS_MQTT_HOST nor EBUS_MQTT_PORT set it finds the broker over mDNS; with either, it connects to that broker:
cargo run -p ebus-rumqttc --example watch -- wildcard
EBUS_MQTT_HOST=localhost cargo run -p ebus-rumqttc --example watch -- tree <root>
EBUS_MQTT_HOST=broker.local EBUS_MQTT_CA=ca.pem cargo run -p ebus-rumqttc --example watch --features rustls -- wildcardIts source lists every setting, including EBUS_BROKER_MODE and EBUS_MDNS_BACKEND: crates/ebus-rumqttc/examples/watch.rs.
Publish a device:
use ebus_rumqttc::ebus_homie_tokio::device::{
DeviceOptions, DeviceSpec, NodeSpec, PropSpec, TreeBuilder, TreeConfig,
};
use ebus_rumqttc::rumqttc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mqtt = rumqttc::MqttOptions::new("my-device", "localhost", 1883);
let mut b = TreeBuilder::new(TreeConfig::default(), "my-device", DeviceSpec::default())?;
let node = b.node(b.root(), "sensor", NodeSpec::typed("example.sensor"))?;
let temperature = b.property::<f64>(node, "temperature", PropSpec::new().unit("°C"))?;
let mut led = b.settable::<bool>(node, "led", PropSpec::new())?;
temperature.set(21.5)?;
led.set(false)?;
let run = ebus_rumqttc::device(mqtt, b, DeviceOptions::default())?;
tokio::spawn(run.task.run());
while let Some(req) = led.next_set().await {
println!("led -> {}", req.value());
req.accept();
}
Ok(())
}The device example adds a duration, an event and a clean shutdown on Ctrl-C, and takes the watch example's broker settings:
cargo run -p ebus-rumqttc --example device -- my-deviceThe tree-device example publishes a parent/child tree on one connection, as python-sdk's simple-tree-device does: a distribution enclosure with three circuits and a battery as children and a microgrid interconnect under the battery, built in one TreeBuilder so the root goes ready once for the tree. It prints each /set it accepts on circuit-3's switch/relay and the battery's control/reserve.
The set example pairs with it, as the --set option of python-sdk's simple-controller does with simple-tree-device: it waits for the device to sync, sends one /set and waits until the device reports the value. The controller's own checks refuse an unknown or non-settable property and an invalid value before anything is sent; the exit code tells these outcomes apart. Both take the watch example's broker settings:
cargo run -p ebus-rumqttc --example tree-device
cargo run -p ebus-rumqttc --example set -- circuit-3/switch/relay true
cargo run -p ebus-rumqttc --example set -- --root rust-tree bess-1/control/reserve 50The simple-span-controller example, a port of python-sdk's simple-span-controller, reports whether a SPAN panel's home is islanded from the grid, watching the MID child's grid node and the enclosure's power-flows node. It finds the panel's broker over mDNS as the _secure-mqtt._tcp advertisement whose SRV target is span-<serial-number>.local, else connects to that host name after 5 s:
cargo run -p ebus-rumqttc --example simple-span-controller --features rustls -- <serial-number> [password]Without a password argument it reads the panel's ebus_broker_password from SPAN_AUTH_FILE, else ~/.span-auth.json, the credential file of span_auth_utils in SPAN-API-Client-Docs. That file is optional, a convenience: its scripts/span-auth writes it (span-auth setup --wait registers with a panel, span-auth list shows the panels, span-auth default picks one). It verifies the broker against the panel's CA certificate, cached as <serial-number>.crt in SPAN_CA_CERT_DIR, else ~/.span-ca-certs, and downloaded from http://<host>/api/v2/certificate/ca when missing or expiring within a day; --insecure skips the check. Its source lists every option: crates/ebus-rumqttc/examples/simple-span-controller.rs.
A service can share its own MQTT client with the controller instead of opening a second connection: ebus_homie_tokio::attach_controller returns an inbox the client's reader feeds and a command stream the client sends. The host keeps its client version, TLS, last will and reconnect loop. The contract is in the ebus-homie-tokio crate docs.
A device can share the connection the same way (ebus_homie_tokio::device::attach_device, or attach_pair with a controller), provided the host installs the device's last will on it. The contract is in the device module docs.
| Crate | Role | no_std |
Version |
|---|---|---|---|
ebus-rumqttc |
The controller, the device, or both, with their own rumqttc 0.25 connection: TCP, or TLS with the rustls or native-tls feature; a reresolve hook finds a discovered broker again after its address changes |
no | |
ebus-homie-tokio |
Runs the controller and the device on tokio over a host-owned MQTT connection, with typed property handles | no | |
ebus-discovery-tokio |
Finds the site's broker over mDNS, finds it again after an address change, and advertises the host's services, on tokio; Avahi backend under the avahi feature (Linux), mdns-sd backend under the mdns-sd feature |
no | |
ebus-homie |
Homie 5 protocol core: identifiers, topics, datatypes, $description, and the controller and device state machines. Performs no I/O |
yes, with optional alloc |
|
ebus-mqtt |
MQTT vocabulary: messages and commands, topic filters and their ownership, and pairing two roles on one connection | yes, with optional alloc |
|
ebus-discovery |
eBus DNS-SD contract: service types, TXT records, the advertisement model, and broker ranking and selection. Performs no I/O | yes, with optional alloc |
The core crates take std (the default), alloc, or neither as cargo features, and are driven by the caller: they receive messages and the current time and return the MQTT commands to send. The design, including subscription pacing, reconnect handling and the Homie state rules, is in doc/design.md.
Each v* tag on this repository publishes all six crates to crates.io at that version and creates the matching GitHub release, whose notes are that version's CHANGELOG section; CONTRIBUTING.md has the steps.
The Homie 5 convention and the eBus specification decide wire behavior. Where the spec leaves a choice open, rust-sdk follows python-sdk; where python-sdk deviates from the spec, rust-sdk follows the spec (python-sdk#110). Shared test vectors generated from python-sdk and python-service-discovery are in vectors/.
The minimum supported Rust version is 1.85. CI also tests 1.91.1 and the current stable release, and builds the core crates for thumbv7em-none-eabihf.
cargo test --workspaceThe end-to-end tests start their own mosquitto and skip when it is not installed. Build commands, test settings and house style are in AGENTS.md; how to contribute is in CONTRIBUTING.md.
MIT; see LICENSE.