Using LR2021 as a Z-Wave transceiver
08 Sep 2025
Writing an ADS-B receiver was fun, but it was also very simple. So let’s do something a bit more ambitious: a Z-Wave smart led.
Introduction to Z-Wave
Z-Wave is a wireless protocol created in 1999 by Zensys (a Danish startup) aimed at Smart IoT. It suffered from he Zigbee competition another mesh-network protocol, but with the more open approach from its latest owner, SiliconLabs, Z-Wave is starting a come back.
From a pure technical point I think that Z-Wave is a better protocol:
- it operates in the ISM band (sub-GHz): this is less busy and provides better range than the 2.4GHz that Zigbee mainly use.
- It provides better range thanks to it lowest range settings: R1 at 9.6kb/s, R2 at 40kb/s and R3 at 100kB/s: those data-rate are more than enough for most Smart IoT application and this naturally provides better range.
- better interoperability with a well defined application layer
Recently SiliconLabs introduced a new data-rate (LR1, for long range): this data-rate does not support the mesh anymore, but it uses an OQPSK-DSSS with a band of 800kHz, allowing a significantly higher transmit power (up to +20dBm depending on the region), increasing again the range compare to the other data-rate.
Configuring LR2021 for Z-Wave
Analog front-end configuration
The radio configuration is similar to other protocol:
- select the TX/RX path (LF for sub-GHz or HF for 2.4GHz),
- run calibration
- set the TX power and ramp time.
With the LR2021 driver this looks like this:
lr2021.set_rx_path(RxPath::LfPath, RxBoost::Off).await.expect("Setting RX path to LF");
lr2021.calib_fe(&[868_400_000, 869_850_000]).await.expect("Front-End calibration");
lr2021.set_pa_lf(PaLfMode::LfPaFsm, 6, 7).await.expect("Set PA HF");
lr2021.set_tx_params(0, RampTime::Ramp8u).await.expect("SetTxParam");
The two RF provided in the calibration function correspond to the two channels use in the EU region. The power is set to 0dBm and ramp time might to 8us, but for a real application this might need to be increased to ensure a clean spectrum, especially for the R1 data-rate.
Base-band configuration
Since the protocol supports different rate running on potentially different RF channel, we need some scanning feature: listening for a short period on each channel for a Z-Wave packet. This is fully handled by the firmware on the LR2021, we just need to configure the scan strategy and then start it:
lr2021.set_packet_type(PacketType::Zwave).await.expect("SetPkt");
let scan_cfg = ZwaveScanCfg::from_region(ZwaveAddrComp::Off, FcsMode::Auto, ZwaveRfRegion::Eu);
lr2021.set_zwave_scan_config(&scan_cfg).await.expect("SetScan");
lr2021.start_zwave_scan().await.expect("Scan");
The scan_cfg
structure contains settings for address filtering, the number of channel to look for (up to 4, including the LR1 data-rate),
and for each channel, the frequency, data-rate and listen time. The listen time is basically calculated so that
all rates can be scan during the time of the smallest preamble size, ensuring that if there is a preamble we won’t miss it.
The method from_region
allows to create the configuration based on the region frequency allocation from the standard,
and using the recommended listen time per channel.
And with those few lines the device is now ready to received any incoming Z-Wave packet on any rate.
Implementing a minimum functional Z-Wave stack
Setup
To test this application, we need an actual Z-Wave network. I used a USB ZStick S2 as the controller and for the application I end up using the evaluation version of HomeSeer: I tried many other free software, but most of them were a pain to install/configure on Windows. Domoticz was actually a good free alternative but unfortunately they drop support for the ZStick (it was good enough to initiate the join procedure, but it could not get the information back).
Spy only mode
Before trying to implement a complex stack, let’s start by making sure we can receive packet and decode them.
The packet format is straight-forward:
- 4 byte for the network address, called HomeID
- 1 byte for the source address of the packet
- 1 byte of header: it contains the type (SingleCast, MultiCast, Acknowledge, Routed), a flag set high when an acknowledge is required
- 1 byte of sequence number. It is used typically inside response to a message received
This is then followed by 2 bytes: a class command, followed by the command itself. And then each command will have its own format.
Message parsing is actually quite nice to write in Rust thanks to its enum, From method and error handling. You can find the whole logic in the zigbee-utils.rs file, but here is an excerpt of the parsing logic:
pub struct ZwavePhyHdr {
pub home_id: u32,
pub hdr_type: ZwaveHdrType,
pub src: u8,
pub dst: u8,
pub seq_num: u8,
pub ack_req: bool,
}
impl ZwavePhyHdr {
/// Extract Phy Header information form a byte stream
pub fn parse(bytes: &[u8]) -> Option<Self>{
let home_id : u32 =
((*bytes.first()? as u32) << 24) +
((*bytes.get(1)? as u32) << 16) +
((*bytes.get(2)? as u32) << 8) +
(*bytes.get(3)? as u32);
let src : u8 = *bytes.get(4)?;
let dst : u8 = *bytes.get(8)?;
let seq_num : u8 = *bytes.get(6)? & 0xF;
let hdr_type: ZwaveHdrType = (*bytes.get(5)? & 0xF).into();
let ack_req = (*bytes.get(5)? & 0x40) != 0; // Note: in channel config 3 the mask should be 0x80
Some(Self {home_id, hdr_type, src, dst, seq_num, ack_req})
}
Now let’s test this parser on actual packet. First we need to setup the chip to send an interrupt each time a packet is received so that we can parse it. We then check the interrupt status to be sure the CRC is ok.
AT the end of the main we have our loop waiting on interrupt:
lr2021.set_dio_irq(DioNum::Dio7, Intr::new(IRQ_MASK_RX_DONE)).await.expect("Setting DIO7 as IRQ");
loop {
irq.wait_for_rising_edge().await;
// On CRC error flash the red led and clean fifo
if intr.crc_error() {
BoardNucleoL476Rg::led_red_set(LedMode::Flash);
lr2021.clear_rx_fifo().await.expect("ClearFifo");
}
// On packet received OK flash the green led and call the parsing function
else if intr.rx_done() {
BoardNucleoL476Rg::led_red_set(LedMode::Flash);
handle_rx_pkt(&mut lr2021).await;
}
}
And the handle_rx_pkt
function code is:
async fn handle_rx_pkt(lr2021: &mut Lr2021Stm32, state: &mut BoardState) {
let status = lr2021.get_zwave_packet_status().await.expect("RX status");
let nb_byte = status.pkt_len() as usize;
lr2021.rd_rx_fifo(nb_byte).await.expect("RX FIFO Read");
let datarate = status.last_detect();
// Try parsing a header
if let Some(rx_phy_hdr) = ZwavePhyHdr::parse(lr2021.buffer()) {
info!("{} | {} | {:02x}",
datarate,
rx_phy_hdr,
&lr2021.buffer()[9..nb_byte.max(9)]
);
}
// It if fails just display the packet
else {
info!("{} | {} | {:02x}",
datarate,
&lr2021.buffer()[..nb_byte]
);
}
let lqi = status.lqi();
let lqi_frac = (lqi&3) * 25;
info!(" - RSSI=-{}dBm, LQI={}.{}", status.rssi_avg()>>1, lqi>>1, lqi_frac);
}
And now when I start the include node procedure on the controller, I can see some messages in the log:
74.337921 [INFO ] R1 | 0184e19d | SingleCast 01 -> ff (1) : Prot(TransferPres) | [01, 08]
74.338043 [INFO ] - RSSI=-61dBm, LQI=23.50
76.341583 [INFO ] R1 | 0184e19d | SingleCast 01 -> ff (2) : Prot(TransferPres) | [01, 08]
76.341705 [INFO ] - RSSI=-71dBm, LQI=22.25
78.345367 [INFO ] R1 | 0184e19d | SingleCast 01 -> ff (3) : Prot(TransferPres) | [01, 08]
78.345520 [INFO ] - RSSI=-69dBm, LQI=22.25
Joining a network
The procedure to join a network is not too complex, when we are in non-secure mode:
- The controller sends a
TransferPresentation
command in broadcast - The unassigned node sends a
NodeInfo
command to indicates that it want to join the network and also provide basic information like the kind of command it supports - The controller then send a
SetId
command to assign an ID to the node, followed by aNop
command with an acknowledge requested to be sure the procedure went well. - The controller then ask the new end-node to find nodes in its range and the end-node must respond with a list of node it found
At this point the joint procedure is technically done, and the application can request information about the node like its name, manufacturer, the version of the different command it supports, …
So let’s just look at how to send the NodeInfo
command when we get a TransferPresentation
command:
after that it will be just a matter of parsing the different command and constructing the correct response for each.
The sequence to send a message following a reception is:
- Switch to FS (Frequency Synthesis) to cleanly exit reception
- Configure the Z-Wave packet: data-rate, preamble length, payload length, …
- Fill the TX FIFO with byte to send
- Call
set_tx
to send the packet
Some parameters needs to be stored like the Home ID, the source address, … We also need to schedule what message to send after a successful reception, and this can occur either immediately or after the transmission of an acknowledge. So let’s put all that in a structure, which can be pass around in the different functions:
struct BoardState {
is_active: bool,
phy_hdr: ZwavePhyHdr,
mode: ZwaveMode,
next_action: Action,
on_tx_done: bool,
}
And now we can create send_message
which take two arguments (state and msg) on top of the handle to the driver:
const HDR_LEN : usize = 9; // Number of bytes in the header
async fn send_message(lr2021: &mut Lr2021Stm32, state: &mut BoardState, msg: &[u8]) {
let len = msg.len() + HDR_LEN;
lr2021.set_chip_mode(ChipMode::Fs).await.expect("SetFs");
let params = ZwavePacketParams::from_mode(state.mode, ZwavePpduKind::SingleCast, len as u8);
lr2021.set_zwave_packet(¶ms).await.expect("SetPacket");
// Copy message in the driver buffer
lr2021.buffer_mut()[..HDR_LEN].copy_from_slice(&state.phy_hdr.to_bytes((len+1) as u8)); // +1 for CRC
if len > HDR_LEN {
lr2021.buffer_mut()[HDR_LEN..len].copy_from_slice(msg);
}
// Write in the FIFO
lr2021.wr_tx_fifo(len).await.expect("FIFO write");
// Start transmission
lr2021.set_tx(0).await.expect("SetTx");
}
Then it is only a matter to do a match on the message received to setup what should be the response.
Inside our handle_rx_pkt
we can now add the following:
if let Some(rx_phy_hdr) = ZwavePhyHdr::parse(lr2021.buffer()) {
let npdu = &lr2021.buffer()[9..nb_byte.max(9)];
let cmd = ZwaveCmd::parse(npdu);
if state.is_active {
// Default destination to TX node
state.phy_hdr.dst = rx_phy_hdr.src;
state.phy_hdr.seq_num = rx_phy_hdr.seq_num;
state.mode = status.last_detect();
// Send Ack when requested
if rx_phy_hdr.ack_req {
state.phy_hdr.hdr_type = ZwaveHdrType::Ack;
send_message(lr2021, state, &[]).await;
} else {
state.phy_hdr.hdr_type = ZwaveHdrType::SingleCast;
}
// Handle the command
match cmd {
ZwaveCmd::Prot(ProtCmd::TransferPres) {
state.phy_hdr.home_id = rx_phy_hdr.home_id;
state.next_action = Action::NodeInfo;
}
_ => {}
}
// Delay the action on TX done if an ACK was requested
state.on_tx_done = state.next_action!=Action::None && rx_phy_hdr.ack_req;
}
}
We now need to update our main loop so that it not only wait on RX Done but also TX Done so that we can send message right after an acknowledge was sent:
// Set DIO7 as IRQ for RX Done
lr2021.set_dio_irq(DioNum::Dio7, Intr::new(IRQ_MASK_RX_DONE|IRQ_MASK_TX_DONE)).await.expect("Setting DIO7 as IRQ");
loop {
irq.wait_for_rising_edge().await;
// On CRC error flash the red led and clean fifo
if intr.crc_error() {
BoardNucleoL476Rg::led_red_set(LedMode::Flash);
lr2021.clear_rx_fifo().await.expect("ClearFifo");
}
// On packet received OK flash the green led and call the parsing function
else if intr.rx_done() {
BoardNucleoL476Rg::led_red_set(LedMode::Flash);
handle_rx_pkt(&mut lr2021).await;
}
// On TxDone either go in scan or send another command (happens after an ack typically)
// If an action is pending not supposed to be trigger by TX done, execute it immediately
if state.next_action != Action::None && ((intr.tx_done() && state.on_tx_done) || !state.on_tx_done) {
state.phy_hdr.hdr_type = ZwaveHdrType::SingleCast;
match state.next_action {
Action::NodeInfo => {
send_message(&mut lr2021, &mut state, &NPU_NODE_INFO).await;
}
a => {
warn!("Action {} not supported", a);
lr2021.start_zwave_scan().await.expect("Scan");
}
}
}
// Restart scan after a transmission if nothing to do
else if intr.tx_done() {
// info!("Scan restarted");
lr2021.start_zwave_scan().await.expect("Scan")
}
}
Once every message/reponse are implemented, we get the following log during the join procedure:
26.223083 [INFO ] R1 | 0184e19d | SingleCast 01 -> ff (1) : Prot(TransferPres) | Next: NodeInfo (false) | [01, 08]
26.282287 [INFO ] - Joining HomeID 25485725 with ID 59
26.283142 [INFO ] R1 | 0184e19d | SingleCast 01 -> 00 (2) AckReq : Prot(SetId) | Next: None (false) | [01, 03, 3b, 01, 84, e1, 9d]
26.592041 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (3) AckReq : Nop | Next: None (false) | [00]
26.691162 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (4) AckReq : Prot(FindNodesInRange) | Next: CmdDone(4) (true) | [01, 04, 01, 01]
26.753234 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (5) AckReq : Prot(GetNodesInRange) | Next: RangeReport (true) | [01, 05]
26.814086 [INFO ] R1 | 0184e19d | Ack 01 -> 3b (5)
27.080413 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (6) AckReq : Prot(AssignSucRoute) | Next: None (false) | [01, 14, 00, 00, 08]
27.125091 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (7) AckReq : Prot(AssignSucRoute) | Next: None (false) | [01, 14, 00, 10, 08]
27.169860 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (8) AckReq : Prot(AssignSucRoute) | Next: None (false) | [01, 14, 00, 20, 08]
27.214782 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (9) AckReq : Prot(AssignSucRoute) | Next: None (false) | [01, 14, 00, 30, 08]
27.461456 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (14) AckReq : Prot(ReqInfo) | Next: NodeInfo (true) | [01, 02]
27.540832 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (15) AckReq : Manufacturer(Get) | Next: Manufacturer (true) | [72, 04]
27.620605 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (1) AckReq : Version(ClassGet(134)) | Next: VersionCls(134) (true) | [86, 13, 86]
27.689270 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (2) AckReq : Version(Get) | Next: Version (true) | [86, 11]
27.766296 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (3) AckReq : Version(ClassGet(32)) | Next: VersionCls(32) (true) | [86, 13, 20]
27.835693 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (4) AckReq : Version(ClassGet(0)) | Next: VersionCls(0) (true) | [86, 13, 00]
27.906005 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (5) AckReq : Version(ClassGet(37)) | Next: VersionCls(37) (true) | [86, 13, 25]
27.975433 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (6) AckReq : Version(ClassGet(114)) | Next: VersionCls(114) (true) | [86, 13, 72]
28.044982 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (7) AckReq : Version(ClassGet(119)) | Next: VersionCls(119) (true) | [86, 13, 77]
28.114868 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (8) AckReq : Naming(NameGet) | Next: NameReport (true) | [77, 02]
28.187469 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (9) AckReq : Naming(LocGet) | Next: LocReport (true) | [77, 05]
28.260528 [INFO ] R1 | 0184e19d | SingleCast 01 -> 3b (10) AckReq : Binary(Get) | Next: BinaryReport (true) | [25, 02]
And the node is now visible in the HomeSeer Interface as a switch:
Controlling exact timing with timestamps
In most wireless protocol, a fine control of transmission is required. In Z-Wave, the acknowledge message should be sent 1ms after the message is received, with a margin corresponding to roughly the packet size.
The LR2021 offers a simple mechanism to get a precise timing information about last reception (or last transmission). It can store up to 3 different time-stamping value with 4 different event: TX Done, RX Done, RX Sync and RX Header. So before the main loop we can set our time-stamping to RX Done:
lr2021.set_timestamp_source(TimestampIndex::Ts0, TimestampSource::RxDone ).await.expect("SetTs");
And then the function get_timestamp
allows to know how many HF clock cycle elapsed since the last timestamp event,
with the value corresponding to the moment the NSS pin on the SPI is going low.
We can calculate how long we should sleep before sending the packet.
And to be avoid being dependent of possible jitter when using the SPI to send the set_tx
command,
the transmission can be started with a pin.
lr2021.set_dio_function(DioNum::Dio8, DioFunc::TxTrigger, PullDrive::PullNone).await.expect("SetDioTxTrigger");
The send_message can now be updated to ensure 1ms delay for the Ack and immediate TX for the other messages:
if state.phy_hdr.hdr_type == ZwaveHdrType::Ack {
let rx_ts_tick = lr2021.get_timestamp(TimestampIndex::Ts0).await.expect("GetTs");
let rx_ts_ns = (rx_ts_tick as u64 * 125) >> 2; // 32MHz -> 31.25ns
// Ensure the packet will starts after ~ 1ms
let sleep = Duration::from_micros(1000) - Duration::from_nanos(rx_ts_ns);
Timer::after(sleep).await;
state.trigger_tx.set_high();
lr2021.wait_ready(Duration::from_micros(100)).await.expect("WaitTxTrigger");
Timer::after_micros(1).await;
state.trigger_tx.set_low();
} else {
lr2021.set_tx(0).await.expect("SetTx");
}
Conclusion
This minimal implementation of the Z-Wave protocol on the end-node side is clearly not ready for certification (mainly missing the mesh aspect), but for a small proof-of-concept it works really well, and I find the code quite easy to update to support new messages. If you want to start an open Z-Wave stack for end-nodes don’t hesitate to use whatever I wrote.
And I want to thanks the LR2012 firmware team: I found the API provided by the LR2021 really well thought, with a good mix of high level command and enough granularity to control the chip exactly how we need it. I think it is quite visible in this apps: most of the code is about Z-Wave and not about configuring the LR2021.