refractor into library crate with examples

This commit is contained in:
2026-02-26 14:16:34 +01:00
parent d624b92704
commit 4ec062f575
24 changed files with 1019 additions and 211 deletions

17
stm32wl-subghz/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "stm32wl-subghz"
version = "0.1.0"
edition = "2024"
license = "MIT"
repository = "https://git.lusia.moe/lukrecja/stm32wl-subghz"
categories = [ "no-std", "embedded", "asynchronous" ]
keywords = [ "stm32wl", "stm32wle5jc", "lora", "fsk", "gfsk", "msk", "gmsk", "bpsk", "radio", "embedded-hal-async" ]
description = "Sub-GHz radio driver for STM32WL-series microcontrollers"
[dependencies]
embassy-stm32 = { version = "0.5.0", features = ["unstable-pac"] }
embassy-time = "0.5.0"
defmt = "1.0.1"
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
embedded-hal = "1.0.0"
embedded-hal-async = "1.0.0"

7
stm32wl-subghz/LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright © 2026 Lukrecja Pleskaczyńska
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

20
stm32wl-subghz/README.md Normal file
View File

@@ -0,0 +1,20 @@
# stm32wl-subghz
[![Latest Version]][crates.io]
Sub-GHZ SPI device radio driver for STM32WL-series microcontrollers, currently supporting LoRa and BPSK.
Built on `embedded-hal-async` and `embassy-stm32`.
## Supported hardware
- STM32WLE5JC (single-core) - tested
- Possibly all the other variants in STM32WL5x and STM32WLEx families
## Usage
See the [examples](../examples/) directory.
## License
MIT
[Latest Version]: https://img.shields.io/crates/v/stm32wl-subghz.svg
[crates.io]: https://crates.io/crates/stm32wl-subghz

View File

@@ -0,0 +1,19 @@
#[derive(Debug, Clone, Copy, defmt::Format)]
pub enum RadioError {
/// SPI comms failed
Spi,
/// Radio busy (polling rfbusys)
Busy,
/// Timeout of tx or rx
Timeout,
/// Rx packet had bad CRC
CrcInvalid,
/// Header was invalid
HeaderInvalid,
/// Payload too large
PayloadTooLarge,
/// Invalid configuration (e.g. power out of range for selected PA)
InvalidConfig,
/// Command not allowed in the current radio state
InvalidState,
}

13
stm32wl-subghz/src/lib.rs Normal file
View File

@@ -0,0 +1,13 @@
#![no_std]
#![allow(async_fn_in_trait)]
pub mod error;
pub mod modulations;
pub mod radio;
pub mod spi;
pub mod traits;
pub use error::RadioError;
pub use radio::{PaSelection, Radio};
pub use spi::SubGhzSpiDevice;
pub use traits::{Configure, Receive, Transmit};

View File

@@ -0,0 +1,154 @@
use defmt::debug;
use embedded_hal::digital::OutputPin;
use embedded_hal_async::spi::SpiDevice;
use crate::{
RadioError,
radio::{PaSelection, PacketType, Radio, RampTime, irq},
traits::{Configure, Transmit},
};
/// BPSK bitrate
/// Formula: register = (32 * 32_000_000) / bps
#[derive(Clone, Copy, defmt::Format)]
pub enum Bitrate {
/// 100 bits per second
Bps100,
/// 600 bits per second
Bps600,
/// Arbitrary bitrate in bits per second
Custom(u32),
}
impl Bitrate {
/// Get the 3-byte register value for this bitrate
fn to_bytes(self) -> [u8; 3] {
let val = match self {
Bitrate::Bps100 => 0x9C4000,
Bitrate::Bps600 => 0x1A0AAA,
Bitrate::Custom(bps) => (32 * 32_000_000) / bps,
};
[(val >> 16) as u8, (val >> 8) as u8, val as u8]
}
}
#[derive(Clone, Copy, defmt::Format)]
pub struct BpskConfig {
pub frequency: u32,
pub bitrate: Bitrate,
pub pa: PaSelection,
pub power_dbm: i8,
pub ramp: RampTime,
}
impl Default for BpskConfig {
fn default() -> Self {
Self {
frequency: 868_100_000,
bitrate: Bitrate::Bps600,
pa: PaSelection::LowPower,
power_dbm: 14,
ramp: RampTime::Us40,
}
}
}
/// BPSK modulation - borrows a Radio, implements Configure + Transmit
pub struct BpskRadio<'a, SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin> {
radio: &'a mut Radio<SPI, TX, RX, EN>,
payload_len: u8,
config: BpskConfig,
}
impl<'a, SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin>
BpskRadio<'a, SPI, TX, RX, EN>
{
pub fn new(radio: &'a mut Radio<SPI, TX, RX, EN>) -> Self {
Self {
radio,
payload_len: 0,
config: BpskConfig::default(),
}
}
/// Re-send SetPacketParams with updated payload length (called before each tx/rx)
async fn update_payload_len(&mut self, len: u8) -> Result<(), RadioError> {
debug!("Updating payload length to {}", len);
if len == self.payload_len {
return Ok(());
}
self.payload_len = len;
self.send_packet_params(len).await
}
/// Send the full SetPacketParams command with the given payload length
async fn send_packet_params(&mut self, payload_len: u8) -> Result<(), RadioError> {
self.radio.set_packet_params(&[payload_len]).await
}
}
impl<SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin> Configure
for BpskRadio<'_, SPI, TX, RX, EN>
{
type Config = BpskConfig;
async fn configure(&mut self, config: &Self::Config) -> Result<(), RadioError> {
self.config = *config;
// Select BPSK packet type
self.radio.set_packet_type(PacketType::Bpsk).await?;
// Payload length updated per tx
self.send_packet_params(0).await?;
// RF frequency
self.radio.set_rf_frequency(config.frequency).await?;
// Set modulation params (bitrate + Gaussian BT 0.5 pulse shape)
let br = config.bitrate.to_bytes();
self.radio
.set_modulation_params(&[br[0], br[1], br[2], 0x16])
.await?;
// PA config + TX power
self.radio
.set_output_power(config.pa, config.power_dbm, config.ramp)
.await?;
Ok(())
}
}
impl<SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin> Transmit
for BpskRadio<'_, SPI, TX, RX, EN>
{
async fn tx(&mut self, data: &[u8]) -> Result<(), RadioError> {
if data.len() > 255 {
return Err(RadioError::PayloadTooLarge);
}
// Write payload to radio buffer
self.radio.set_buffer_base(0x00, 0x00).await?;
self.radio.write_buffer(0x00, data).await?;
// Update packet params with actual payload length
self.update_payload_len(data.len() as u8).await?;
// Clear any stale IRQ flags before starting TX
self.radio.clear_irq(irq::ALL).await?;
// Enable TxDone IRQ on DIO1
self.radio.set_dio1_irq(irq::TX_DONE | irq::TIMEOUT).await?;
// Start TX
self.radio.set_tx(0).await?;
// Wait until it's done or until timeout
let status = self.radio.poll_irq(irq::TX_DONE | irq::TIMEOUT).await?;
if status & irq::TIMEOUT != 0 {
return Err(RadioError::Timeout);
}
Ok(())
}
}

View File

@@ -0,0 +1,277 @@
use defmt::{debug, trace};
use embedded_hal::digital::OutputPin;
use embedded_hal_async::spi::SpiDevice;
use crate::error::RadioError;
use crate::radio::{PaSelection, PacketType, Radio, RampTime, irq};
use crate::traits::{Configure, Receive, Transmit};
#[derive(Clone, Copy, defmt::Format)]
#[repr(u8)]
pub enum SpreadingFactor {
SF5 = 0x05,
SF6 = 0x06,
SF7 = 0x07,
SF8 = 0x08,
SF9 = 0x09,
SF10 = 0x0a,
SF11 = 0x0b,
SF12 = 0x0c,
}
#[derive(Clone, Copy, defmt::Format)]
#[repr(u8)]
pub enum Bandwidth {
Bw7_8kHz = 0x00,
Bw10_42kHz = 0x08,
Bw15_63kHz = 0x01,
Bw20_83kHz = 0x09,
Bw31_25kHz = 0x02,
Bw41_67kHz = 0x0a,
Bw62_50kHz = 0x03,
Bw125kHz = 0x04,
Bw250kHz = 0x05,
Bw500kHz = 0x06,
}
#[derive(Clone, Copy, defmt::Format)]
#[repr(u8)]
pub enum CodingRate {
/// No forward error correction coding
Cr44 = 0x00,
Cr45 = 0x01,
Cr46 = 0x02,
Cr47 = 0x03,
Cr48 = 0x04,
}
#[derive(Clone, Copy, defmt::Format)]
pub struct LoraConfig {
pub frequency: u32,
pub sf: SpreadingFactor,
pub bw: Bandwidth,
pub cr: CodingRate,
pub ldro: bool,
pub preamble_len: u16,
pub explicit_header: bool,
pub crc_on: bool,
pub iq_inverted: bool,
pub sync_word: u16,
pub pa: PaSelection,
pub power_dbm: i8,
pub ramp: RampTime,
}
impl Default for LoraConfig {
fn default() -> Self {
Self {
frequency: 868_100_000,
sf: SpreadingFactor::SF7,
bw: Bandwidth::Bw125kHz,
cr: CodingRate::Cr45,
ldro: false,
preamble_len: 8,
explicit_header: true,
crc_on: true,
iq_inverted: false,
sync_word: 0x1424, // private LoRa network
pa: PaSelection::LowPower,
power_dbm: 14,
ramp: RampTime::Us40,
}
}
}
/// LoRa modulation - borrows a Radio, implements Configure + Transmit + Receive
pub struct LoraRadio<'a, SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin> {
radio: &'a mut Radio<SPI, TX, RX, EN>,
payload_len: u8,
config: LoraConfig,
}
impl<'a, SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin>
LoraRadio<'a, SPI, TX, RX, EN>
{
pub fn new(radio: &'a mut Radio<SPI, TX, RX, EN>) -> Self {
Self {
radio,
payload_len: 0,
config: LoraConfig::default(),
}
}
/// Re-send SetPacketParams with updated payload length (called before each tx/rx)
async fn update_payload_len(&mut self, len: u8) -> Result<(), RadioError> {
debug!("Updating payload length to {}", len);
if len == self.payload_len {
return Ok(());
}
self.payload_len = len;
self.send_packet_params(len).await
}
/// Send the full SetPacketParams command with the given payload length
async fn send_packet_params(&mut self, payload_len: u8) -> Result<(), RadioError> {
self.radio
.set_packet_params(&[
(self.config.preamble_len >> 8) as u8,
self.config.preamble_len as u8,
!self.config.explicit_header as u8,
payload_len,
self.config.crc_on as u8,
self.config.iq_inverted as u8,
])
.await
}
}
impl<SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin> Configure
for LoraRadio<'_, SPI, TX, RX, EN>
{
type Config = LoraConfig;
async fn configure(&mut self, config: &LoraConfig) -> Result<(), RadioError> {
self.config = *config;
// Select LoRa packet type
self.radio.set_packet_type(PacketType::LoRa).await?;
// Calibrate image for this frequency band
let band = Radio::<SPI, TX, RX, EN>::image_cal_for_freq(config.frequency);
self.radio.calibrate_image(band).await?;
// RF frequency
self.radio.set_rf_frequency(config.frequency).await?;
// Modulation: SF, BW, CR, LDRO
self.radio
.set_modulation_params(&[
config.sf as u8,
config.bw as u8,
config.cr as u8,
config.ldro as u8,
])
.await?;
// Packet params (payload length 0 for now, updated per tx/rx)
self.send_packet_params(0).await?;
self.payload_len = 0;
// Fix IQ polarity for non-inverted IQ (set bit 2 of register 0x0736)
if !config.iq_inverted {
let mut iqpol = [0u8; 1];
self.radio.read_register(0x0736, &mut iqpol).await?;
trace!("Got data {:x}", iqpol);
self.radio
.write_register(0x0736, &[iqpol[0] | 0x04])
.await?;
}
// Sync word at SUBGHZ_LSYNCR (0x0740)
self.radio.set_lora_sync_word(config.sync_word).await?;
// PA config + TX power (uses optimal settings from the datasheet)
self.radio
.set_output_power(config.pa, config.power_dbm, config.ramp)
.await?;
Ok(())
}
}
impl<SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin> Transmit
for LoraRadio<'_, SPI, TX, RX, EN>
{
async fn tx(&mut self, data: &[u8]) -> Result<(), RadioError> {
if data.len() > 255 {
return Err(RadioError::PayloadTooLarge);
}
// Write payload to radio buffer
self.radio.set_buffer_base(0x00, 0x80).await?;
self.radio.write_buffer(0x00, data).await?;
// Update packet params with actual payload length
self.update_payload_len(data.len() as u8).await?;
// Clear any stale IRQ flags before starting TX
self.radio.clear_irq(irq::ALL).await?;
// Enable TxDone IRQ on DIO1
self.radio.set_dio1_irq(irq::TX_DONE | irq::TIMEOUT).await?;
// Start TX
self.radio.set_tx(0).await?;
// Wait until it's done or until timeout
let status = self.radio.poll_irq(irq::TX_DONE | irq::TIMEOUT).await?;
if status & irq::TIMEOUT != 0 {
return Err(RadioError::Timeout);
}
Ok(())
}
}
impl<SPI: SpiDevice, TX: OutputPin, RX: OutputPin, EN: OutputPin> Receive
for LoraRadio<'_, SPI, TX, RX, EN>
{
async fn rx(&mut self, buf: &mut [u8], timeout_ms: u32) -> Result<usize, RadioError> {
// Set max payload length we can accept
let max_len = buf.len().min(255) as u8;
self.update_payload_len(max_len).await?;
// Set buffer base addresses (both at 0, same as in lora-rs)
self.radio.set_buffer_base(0x00, 0x00).await?;
// Clear any stale IRQ flags before starting RX
self.radio.clear_irq(irq::ALL).await?;
// Enable RX-related IRQs on DIO1
self.radio
.set_dio1_irq(irq::RX_DONE | irq::TIMEOUT | irq::CRC_ERR | irq::HEADER_ERR)
.await?;
// Stop RX timer on preamble detection (required for proper RX behavior)
self.radio.set_stop_rx_timer_on_preamble(true).await?;
// Set RX gain (0x94 = normal, 0x96 = boosted)
self.radio.write_register(0x08AC, &[0x94]).await?;
// Convert ms to 15.625µs steps (ms * 64), 0 = single mode, 0xFFFFFF = continuous
let timeout_steps = if timeout_ms == 0 {
0
} else {
timeout_ms.saturating_mul(64).min(0xFFFFFF)
};
self.radio.set_rx(timeout_steps).await?;
// Wait for something to happen
let status = self
.radio
.poll_irq(irq::RX_DONE | irq::TIMEOUT | irq::CRC_ERR | irq::HEADER_ERR)
.await?;
// Check what happened
if status & irq::TIMEOUT != 0 {
return Err(RadioError::Timeout);
}
if status & irq::CRC_ERR != 0 {
return Err(RadioError::CrcInvalid);
}
if status & irq::HEADER_ERR != 0 {
return Err(RadioError::HeaderInvalid);
}
// Read received data from the radio buffer
let (len, offset) = self.radio.get_rx_buffer_status().await?;
let read_len = len.min(buf.len() as u8);
self.radio
.read_buffer(offset, &mut buf[..read_len as usize])
.await?;
trace!("Got data {:x}", &mut buf[..read_len as usize]);
Ok(read_len as usize)
}
}

View File

@@ -0,0 +1,2 @@
pub mod bpsk;
pub mod lora;

1049
stm32wl-subghz/src/radio.rs Normal file

File diff suppressed because it is too large Load Diff

54
stm32wl-subghz/src/spi.rs Normal file
View File

@@ -0,0 +1,54 @@
use defmt::trace;
use embassy_stm32::pac;
use embedded_hal_async::spi::{ErrorType, Operation, SpiBus, SpiDevice};
/// Wrapper for the sub-GHz SPI device
pub struct SubGhzSpiDevice<T>(pub T);
impl<T: SpiBus> ErrorType for SubGhzSpiDevice<T> {
type Error = T::Error;
}
/// This works as a translation layer between normal SPI transactions and sub-GHz device SPI
/// transactions. Everything above this layer sees it like a normal SPI device!
impl<T: SpiBus> SpiDevice for SubGhzSpiDevice<T> {
/// Perform a transaction on the sub-GHz device
async fn transaction(
&mut self,
operations: &mut [Operation<'_, u8>],
) -> Result<(), Self::Error> {
// Pull NSS low to allow SPI comms
pac::PWR.subghzspicr().modify(|w| w.set_nss(false));
trace!("NSS low");
for operation in operations {
match operation {
Operation::Read(buf) => {
self.0.read(buf).await?;
trace!("Read {:x}", buf);
}
Operation::Write(buf) => {
self.0.write(buf).await?;
trace!("Wrote {:x}", buf);
}
Operation::Transfer(read, write) => {
self.0.transfer(read, write).await?;
trace!("Read {:x} wrote {:x}", read, write);
}
Operation::TransferInPlace(buf) => {
self.0.transfer_in_place(buf).await?;
trace!("Read+wrote {:x}", buf);
}
Operation::DelayNs(_) => {}
}
}
// Pull NSS high
pac::PWR.subghzspicr().modify(|w| w.set_nss(true));
trace!("NSS high");
// Small delay for the radio to assert BUSY after NSS goes high
cortex_m::asm::delay(500);
// Poll BUSY flag until it's done
while pac::PWR.sr2().read().rfbusys() {}
trace!("BUSY flag clear");
Ok(())
}
}

View File

@@ -0,0 +1,20 @@
use crate::error::RadioError;
/// Can configure the radio
pub trait Configure {
/// Each modulation has its own `Config` struct
type Config;
async fn configure(&mut self, config: &Self::Config) -> Result<(), RadioError>;
}
/// Can send data
pub trait Transmit {
async fn tx(&mut self, data: &[u8]) -> Result<(), RadioError>;
}
/// Can receive data
pub trait Receive {
/// Returns the number of bytes received
async fn rx(&mut self, buf: &mut [u8], timeout_ms: u32) -> Result<usize, RadioError>;
}