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.
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_reqandconfigureReporting(2 bound, 2 configured, 0 failed), unsolicited reports arriving as typed state, andDeviceCommand::SetOnactuating 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 realgenOnOffcommand 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_reqandconfigureReporting, and an arriving report becomes typed state — a caller seestemperature: 21.37, not cluster0x0402attribute0x0000=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.
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 |
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:
rszigbee-coredoes not know EZSP exists. Onlyrszigbee-adapter-emberdepends onrsezspor a serial port. Adding a second coordinator family touches one crate.rszigbee-corehas no MQTT, and no JSON by default. Serialisation lives at system boundaries; persistence is behind afile-storefeature so a caller using onlyMemoryStorelinks no JSON parser.rszigbee-specis 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.rsruns 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.
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.
cargo test --workspace --all-features # no hardware needed
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all
./scripts/check-boundaries.shAgainst real hardware:
./scripts/deploy-remote.sh user@host # if the dongle is elsewhere
cargo run -p rszigbee --example ember_selftest -- /dev/ttyUSB0Without any hardware:
cargo run -p rszigbee --example runtime_mockruntime_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.
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.
ezspandashv2(MIT) — carried this project's EZSP transport through its first working coordinator and its first device join. Now replaced byrsezsp, which we wrote to own the version-aware wire format directly;ezspremains a behavioural reference for frame layouts.
Full notices in THIRD_PARTY_LICENSES.md;
more detail on what is owed to whom in ATTRIBUTION.md.
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.
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.