Skip to content

Repository files navigation

rszigbee

A Rust-native Zigbee stack, built for two uses from one runtime: an embeddable library with a typed API, and a Zigbee2MQTT-compatible MQTT gateway.

                        rszigbee core
                             │
                       typed Rust API
                             │
              ┌──────────────┼──────────────┐
         Embedded API   MQTT adapter   future adapters
              │              │
         Rust apps    Zigbee2MQTT-compatible MQTT

Status: early, but a real device works end to end. The runtime, the coordinator adapter, the protocol codecs, persistence and the device-compatibility engine all work, and the whole chain — join, commission, interview, resolve a definition, bind, configure reporting, receive typed state, send a command that actuates — is confirmed against physical hardware. The MQTT gateway works against a real broker but lacks Home Assistant discovery and most bridge requests, so it is not a drop-in replacement yet. See Status.

Status

Verified on a Sonoff ZBDongle-E (EFR32MG21, EmberZNet 7.4.4.0, EZSP v13):

  • Serial → ASHv2 → EZSP session bring-up, with per-dongle serial settings
  • Forming a network, and resuming it across a restart
  • Persistence surviving a restart
  • The runtime itself, not just the adapter: start, the device table, the ZDO interview through Zigbee::interview, ZCL transaction correlation, and a clean stop — ember_runtime
  • A real device, end to end (a SONOFF SWV-ZNU water valve): joining and commissioning, the interview, definition resolution from the bundled set, Bind_req and configureReporting (2 bound, 2 configured, 0 failed), unsolicited reports arriving as typed state, and DeviceCommand::SetOn actuating the valve with the device reporting its new state back
  • The network key and frame counter read back from the coordinator and persisted, so a stored network can actually restore one

Driving hardware through the runtime rather than the adapter is what found most of the bugs worth finding. Six of them only existed against real firmware: a trust-centre policy whose enum name means the opposite of its value on modern EmberZNet, a stack profile the NCP defaults to 0, a malformed importTransientKey frame in a dependency, ZDO requests that asked about the coordinator instead of the device, definitions that were generated but never shipped, and an OTA query nothing answered. Each was invisible to a mock, because a mock is a model of what we already believed.

Working against the mock coordinator, so verifiable anywhere:

  • The runtime: Zigbee::builder(...), one task owning the adapter, a cloneable handle, an event stream per consumer
  • The device table, with short-address resolution and rejoin handling
  • ZDO interview — node descriptor, endpoints, simple descriptors
  • Incoming ZCL decoded to typed attributes and named commands
  • Reachability, with the availability policy injected

Device definitions: the format, the matcher, and the transcoder that imports upstream's catalogue. Resolution is verified against zigbee-herdsman-converters' own resolver — 6,942 devices, agreeing on both which definition matches and what the unit is called, zero disagreements — and the transcoder's extracted match rules are cross-checked against upstream's runtime for all 4,473 definitions, also with zero divergence.

Measured device coverage is in COVERAGE.md, regenerated by scripts/refresh-device-coverage.sh. It is 48.9% usable today, and the report ranks what to implement next by how many devices each missing primitive would unlock — which is how that number moved from 39.4%.

Those definitions ship, as generated Rust, and are what the builder starts with: all 4,473 of them, carrying 8,350 capability references of which 373 are recorded as Extend::Unsupported. Turn off the default bundled-devices feature to start from an empty index and supply your own.

Note that this and the 48.9% measure different things and will not move together. The coverage report classifies whole definitions by whether anything in them is unexpressed; this counts individual capabilities. A definition with one unsupported attribute out of ten is incomplete in the first number and 90% resolved in the second.

That is worth stating explicitly because it was not true until recently. The transcoder produced a coverage report and no Rust, DefinitionIndex::new() — documented as "an empty index" — was the builder's default, and so a caller got a runtime that resolved nothing while the report described thousands of supported devices. Every test passed, because everything ran against a mock that never asked for a definition. A capability number is only meaningful if the thing it describes is what a caller receives.

The runtime consumes definitions, in both directions:

  • Actuator path. zigbee.send(ieee, DeviceCommand::SetOn(true)) becomes a real genOnOff command on the endpoint the definition names. A device with no definition, or one whose definition does not give it that capability, is refused explicitly and nothing reaches the radio.
  • Sensor path. Resolving a definition triggers Bind_req and configureReporting, and an arriving report becomes typed state — a caller sees temperature: 21.37, not cluster 0x0402 attribute 0x0000 = 2137.

The MQTT gateway works, and is confirmed against a real broker and a real device: an off-the-shelf mosquitto_pub opened and closed a valve through it, and permit_join round-tripped. rszigbee-mqtt holds the contract and no client; rszigbee-gateway holds the client and the loop. What a subscriber sees:

zigbee2mqtt/bridge/state                 {"state":"online"}
zigbee2mqtt/<ieee>/set                   {"state":"ON"}
zigbee2mqtt/<ieee>                       {"battery":100,"state":"ON"}
zigbee2mqtt/bridge/request/permit_join   {"time":30}
zigbee2mqtt/bridge/response/permit_join  {"data":{"time":30},"status":"ok"}

Still missing: Home Assistant discovery, bridge/devices and bridge/info, friendly names, availability topics, and every bridge/request other than permit_join — an unimplemented one is answered with an error rather than silence. Only capabilities the device definition expresses are published, so a device whose definition is incomplete publishes less than the reference would. It is not a drop-in replacement yet.

The contract is observed, not read. Zigbee2MQTT is GPL-3.0 and its source has deliberately not been read; every topic and payload was captured by running it against this same coordinator and reading what it put on the wire, and the inbound direction was confirmed by publishing to <base>/<ieee>/set and watching the valve open and close. An interface reproduced from its observable behaviour is a contract; a translation of an implementation would be a derived work.

390 tests, none of which need hardware.

Crates

You depend on one crate. The rest are internal boundaries.

[dependencies]
rszigbee = { version = "0.0", features = ["ember"] }
let (adapter, adapter_events) = EmberAdapter::serial("/dev/ttyUSB0").build();
let store = FileStore::open("./rszigbee-data").await?;

// The default refuses to form a network, because forming one when we should
// have resumed orphans every joined device.
let zigbee = Zigbee::builder(adapter, adapter_events, store).start().await?;
zigbee.permit_join(Duration::from_secs(60), None).await?;

let mut events = zigbee.events();
while let Some(event) = events.recv().await {
    println!("{event:?}");
}
Crate Role
rszigbee the facade — what you depend on
rszigbee-spec ZCL, ZDO and Tuya codecs, the 129-cluster registry, address types. Sans-IO
rszigbee-adapter the CoordinatorAdapter trait and a mock adapter
rszigbee-adapter-ember Silicon Labs EmberZNet, via EZSP over ASHv2
rszigbee-core the runtime, device model, state, events, commands, reachability, persistence
rszigbee-devices declarative device definitions and the matcher
rszigbee-mqtt the Zigbee2MQTT topic and payload contract. Sans-IO
rszigbee-gateway the MQTT client and the loop that joins a runtime to a broker

Boundaries

The rules that hold the design together are checked in CI by scripts/check-boundaries.sh rather than left to discipline. Eight checks run; these are the three that matter most:

  1. rszigbee-core does not know EZSP exists. Only rszigbee-adapter-ember depends on rsezsp or a serial port. Adding a second coordinator family touches one crate.
  2. rszigbee-core has no MQTT, and no JSON by default. Serialisation lives at system boundaries; persistence is behind a file-store feature so a caller using only MemoryStore links no JSON parser.
  3. rszigbee-spec is sans-IO. No tokio, no serial, no I/O. That is what makes the codecs cheap to test and to fuzz — and they are fuzzed, which for a long time this line only implied. Four decoders read bytes a device chose (ZCL frames and values, ZDO responses, Tuya datapoints), so a panic in one is remote denial of service. crates/rszigbee-spec/tests/fuzz_codecs.rs runs on stable in every CI pass: random input, every truncation of a captured frame, single-byte mutations, and pathological lengths, with a control asserting the corpus actually reaches the parsers rather than bouncing off validation. fuzz/ holds coverage-guided targets for longer runs — 34.7 million executions across the three so far, no crashes.

The rest keep rszigbee-devices from depending on the runtime (so definitions stay data the runtime interprets, not the reverse), keep ZigbeeStore to Zigbee domain state, keep rszigbee-mqtt free of an MQTT client so the contract stays testable without a broker, and keep the workspace free of unsafe.

Each rule has a negative control — deliberately breaking it makes the check fail and name the offending crate.

Design notes

The decisions most likely to surprise someone reading the code.

Malformed input never panics. Radio frames, device-reported strings and length prefixes are untrusted. Every decoder returns Result, and no parse path uses slice indexing, unwrap, expect or panic! — enforced by four denied clippy lints, relaxed only inside tests. A device claiming 200 endpoints while sending two is a typed error, not a read past the end.

ZCL's "invalid" encodings are values, not errors. 0xffff for a uint16 means "no reading". Treating it as an error turns normal traffic into a failure stream; treating it as zero reports 0 °C when the sensor means "I don't know". ZclValue::Invalid is a first-class variant.

State deltas, and actions are not state. Event::StateChanged carries only what changed. A button press is Event::Action, a separate variant — upstream has to fold actions into the state object and then exclude them again through a hard-coded ignore list, and making the distinction structural removes that.

Raw and decoded events coexist. An unknown device still emits ZclMessage and UnparsedFrame, so it is useful with no definition at all — which is what someone needs in order to contribute one.

Capabilities are not exposes. The internal model uses typed units and named access flags, with the endpoint kept separate from the identifier. The MQTT layer owns the mapping to Zigbee2MQTT's exposes shape, so the compatibility constraints stay in one place.

Reachability facts live in core; availability policy is injected. Core owns last_seen, transmit outcomes and one probe scheduler. When and whether to probe comes from a ReachabilityPolicy, so an embedded application gets reachability without running MQTT.

Forming a network requires an explicit opt-in. MismatchPolicy::Fail is the default. Forming when we should have resumed orphans every joined device, and that is not recoverable without re-pairing all of them. The network key comes from the OS CSPRNG, and forming fails closed if that is unavailable.

Persistence is one file per device, written atomically. A corrupt device file is quarantined and startup continues. A corrupt network file stops startup, because continuing means forming a new network. 64-bit values are written as hex strings — an extended PAN id exceeds 2^53, where a JSON consumer using doubles corrupts it silently.

The escape hatch is local, not total. Roughly a quarter of upstream's catalogue needs behaviour a table cannot express — a datapoint that unpacks into several structured entries, a configure step with a real decision in it. The tempting answer is to keep growing the declarative format until it can say those things, and the end of that road is a schema that has become a badly designed programming language. Instead a definition names a behaviour and the runtime looks it up, attached to one datapoint rather than to the whole device — so everything else stays declarative and stays maintained by the importer. Behaviours return Handled or NotHandled, and NotHandled never drops into a generic best effort.

Device support is data, and coverage is measured rather than estimated. The format expresses five things — helper references, Tuya datapoint tables, a bind/report table, endpoint name maps, and a Rust escape hatch — chosen by measuring upstream rather than by taste: shared helpers alone plateau at 57.9% of its 4,473 definitions no matter how many are implemented.

The number that counts comes out of a pipeline, not a survey: extract with the TypeScript compiler's own parser, transcode, cross-validate the match rules against upstream's runtime, then verify in cargo test. A static estimate put this near 78%; the pipeline says 39.4%, and the pipeline is right. Anything that could not be expressed is named, counted, and ranked by how many devices implementing it would unlock — so the next move is "implement one primitive, unlock 824 devices", not "support more things".

complete and approximate are never merged. A Hue bulb mapped through m.light works as a light while its gradient effects are not expressed; that is useful and it is not a transcription, so it is reported as its own state. And the transcoder's claim about which primitives it can emit is itself checked by a test, because an unchecked claim is how a coverage number rises without any device becoming more usable.

A capability implies its own reporting. Upstream's m.temperature() configures reporting as part of what it means, so a definition transcoded from it has no explicit binding at all. If the plan only followed explicit bindings, such a device would join, interview, resolve, advertise a temperature — and never report one, which is indistinguishable from a broken sensor. The plan is derived from the capabilities as well, and the scaling comes with them: the same helper takes no arguments, so a definition carries no divisor, and reading one from the definition would report 2137 °C.

One task owns the adapter; everything else is a handle. CoordinatorAdapter takes &mut self because a coordinator is one serial port with one framing state machine — concurrent use is a protocol violation, not a performance question. Rather than a lock, whose ordering is an accident of scheduling, one task owns it and Zigbee is a cheap clone that asks. Interviews run outside that loop: a ZDO response arrives as an adapter event, so awaiting one inside the loop would deadlock on a message only the loop can deliver.

Four extension points, and each is exercised by a test rather than only declared. CoordinatorAdapter (the radio), ZigbeeStore (persistence), ReachabilityPolicy (when a device counts as gone) and DeviceBehavior (behaviour a definition cannot express). The adapter is the odd one out and deliberately: it is one serial port with one framing state machine, so it is owned exclusively by the runtime task and is not Sync — concurrent use is a compile error rather than a rule in a comment.

ZigbeeStore has two backends, so store::conformance asserts the promises callers rely on — upsert updates in place, deleting something absent succeeds, a backup is never overwritten — against both. Two implementations tested only separately are two implementations free to drift apart. The other three each have a test that injects one and checks the runtime actually consults it; an extension point with no test is an extension point that might not be wired up.

Development

cargo test --workspace --all-features      # no hardware needed
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all
./scripts/check-boundaries.sh

Against real hardware:

./scripts/deploy-remote.sh user@host                 # if the dongle is elsewhere
cargo run -p rszigbee --example ember_selftest -- /dev/ttyUSB0

Without any hardware:

cargo run -p rszigbee --example runtime_mock

runtime_mock drives the whole runtime against a mock coordinator — a join, a temperature report, a rejoin at a new short address. Against real hardware, ember_runtime drives the runtime and ember_selftest drives the adapter; both interview the coordinator, which needs no other device on the network. Binding and attribute reporting do need a second device, and stay unverified against hardware until one exists.

Credit

rszigbee is a reimplementation. It exists because of work other people did first, and it would not be possible without it.

  • zigbee-herdsman (MIT) — the ZCL and ZDO definition data, the coordinator adapter boundary, the coordinator endpoint cluster lists, and the interview quirks that no specification will tell you about. Data is transcoded with attribution; design is reference.
  • zigbee-herdsman-converters (MIT) — compatibility knowledge for thousands of devices, contributed by hundreds of people who each bought hardware and worked out how it behaves. rszigbee's device data will originate here. Device fixes belong upstream, where the whole ecosystem benefits.
  • Zigbee2MQTT (GPL-3.0) — defines the MQTT contract this project treats as an external API to reproduce. No Zigbee2MQTT code is copied or translated; see Licence.
  • zigpy/ziggurat (Apache-2.0) and apis-saltans (MIT) — both informed the architecture. apis-saltans independently arrived at almost the same coordinator-adapter boundary, which is good evidence it is the right one.
  • uplg/maison (MIT) — showed that Rust can drive real Silicon Labs hardware, and its EZSP bring-up sequence was the reference for ours.
  • ezsp and ashv2 (MIT) — carried this project's EZSP transport through its first working coordinator and its first device join. Now replaced by rsezsp, which we wrote to own the version-aware wire format directly; ezsp remains a behavioural reference for frame layouts.

Full notices in THIRD_PARTY_LICENSES.md; more detail on what is owed to whom in ATTRIBUTION.md.

Licence

MIT OR Apache-2.0, intended across the whole workspace — including the MQTT layer — so that embedders, vendors and other Rust projects can all use it.

Not yet final. zigbee-herdsman and zigbee-herdsman-converters are MIT, so transforming their data is permitted with attribution. Zigbee2MQTT is GPL-3.0, and it defines the MQTT contract the compatibility layer targets. The position is that reproducing an interface from observed behaviour differs from translating an implementation: nobody implementing the MQTT layer works from Zigbee2MQTT source, the Home Assistant property tables will be rebuilt from Home Assistant's own documentation, and harvested test fixtures stay test-only.

One dependency carries an obligation of its own: serialport, reached through tokio-serial under the ember feature, is MPL-2.0. That is file-level copyleft and does not change the licence of rszigbee's own code, but anyone shipping a binary has to pass the notice along and make that source available. Details in THIRD_PARTY_LICENSES.md.

Both positions need review by a lawyer who knows OSS licensing before release. If it does not hold, the fallback is GPL-3.0-or-later for the MQTT layer and the CLI with the core staying permissive — the workspace is laid out so that costs one Cargo.toml field per crate and no code movement.

Contributing

See CONTRIBUTING.md. Adding support for a Zigbee device should not require understanding the protocol implementation, and keeping that true is a design goal rather than an aspiration.

Security reports go through GitHub's private advisory form — see SECURITY.md.

About

A Rust-native Zigbee stack: typed embedded API and a Zigbee2MQTT-compatible gateway.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages