Skip to content
electrification-busPublic

About

Rust SDK for eBus/Homie devices and controllers: MQTT transport, mDNS discovery

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

rust-sdk

CI crates.io docs.rs MSRV 1.85 License: MIT

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.

What it does

Controller

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 ebus domain, the devices of one tree (a root and its children), or a single device;
  • applies each device's $description and checks every value against its property's datatype and format;
  • tracks each device's $state and its effective state (a child of a lost root is lost);
  • 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 /set and $broadcast, checking a /set payload 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.

Device

A device publishes a tree of Homie devices (a root and its children) and takes commands for them. It:

  • builds each device's $description from typed properties (property::<f64>, settable::<bool>, event::<i64>) and publishes init, the description, the values and ready, the root's $state last;
  • publishes a value only when it changes, and never publishes events while disconnected;
  • delivers each valid /set to the application, which accepts it, accepts it with the value the device reached, or rejects it; $target properties report progress toward the target;
  • republishes the whole tree after a reconnect, and installs the root's lost state 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).

Broker discovery

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.

Quick start

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-thread

Watch 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 -- wildcard

Its 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-device

Device tree and /set examples

The 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 50

SPAN panel example

The 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.

Inside a service that already has an MQTT connection

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.

Crates

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 crates.io
ebus-homie-tokio Runs the controller and the device on tokio over a host-owned MQTT connection, with typed property handles no crates.io
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 crates.io
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 crates.io
ebus-mqtt MQTT vocabulary: messages and commands, topic filters and their ownership, and pairing two roles on one connection yes, with optional alloc crates.io
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 crates.io

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.

Spec compliance

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/.

Rust version

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.

Development

cargo test --workspace

The 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.

License

MIT; see LICENSE.

About

Rust SDK for eBus/Homie devices and controllers: MQTT transport, mDNS discovery

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages