Part 3 in which I got everything to work (I THINK)!
This is experimental and shouldn't be used in production. Don't transmit random stuff on ISM bands without understanding local duty cycle limits, ERP restrictions and other regulations!
- Cleaning up the crate: SPI layer, Radio abstraction, traits
- Implementing BPSK transmission
- Implementing MSK transmission (and figuring out the weird packet type)
- Implementing FSK transmission
Introduction
In part 2 of this series, I managed to manually talk to the integrated sub-GHz device and the result was a successful transmission of a LoRa packet! I was REALLY happy and I knew it'll only get easier from now on.
My goal for now was to clean up the code, make it configurable and expose user-friendly functions and structs for using the radio. For that, I decided I wanna implement this particular design:
- a
SubGhzSpiDevice- SPI translation layer, which I already implemented in part 2 - a
Radiowhich handles low-level communications viaSubGhzSpiDevice- this, obviously, ended up being the largest module in the whole program Configure,TransmitandReceivetraits which would be used to specify which radio is configurable (each is ofc), which can do tx and which can do rx- each modulation, eg.
LoraRadiowhich borrows aRadioand implements appropriate traits - modulation-specific configs would be implemented there, along with specific configuration sequences. Another important detail was to implement a state machine for theRadio, so that it is known during runtime in which state the radio currently is, and to prevent invalid state transitions - a common
RadioErrorenum, for easier error handling
I decided to be a lazy slut and use Claude Code for that boring task, especially when I had everything already planned out, and I knew refactorization is a mechanical, boring work. I was surprised by the results!
Stoopid clanker
I fired up a session with Opus 4.6, typed my long prompt and answered a few questions. After some time, it finished and I was really surprised! Not only did it refactor the program into a structure I wanted, it also implemented every low-level function. I was very sceptical at first, but I ended up comparing every function with RM0461 and SX1261/2 reference manuals and it ended up being like 99% correct. I only had to manually fix up a couple of them. I asked it if it has these manuals fucking memorized or what, and apparently yes?
Weird.
Code cleanup
I decided to further analyze the generated code, clean it up and add my own comments, which was boring. But all in all, it most probably took less time than manual work. I'm not sure if I'd use an LLM for a work on way more complex embedded projects, but it handled this particular task surprisingly well.
I tested it and yep, I got a valid LoRa transmission!
The code worked exactly as expected. As I said before, the core idea was to have this structure:
Modulation-specific radio (eg. `LoraRadio`)
|
| user-specified configs
v
`Radio` for translating everything to raw op-codes, addresses, etc
|
| low-level SPI commands
v
`SubGhzSpiDevice`
|
| internal SPI comms
v
`SUBGHZSPI` peripheral
So what are all these things?
SubGhzSpiDevice
Is at the bottom of the stack. I explained it in previous parts, but in short, this module wraps one quirk of comms with SUBGHZSPI peripheral - instead of communicating with it like with a regular SPI device, it's necessary to pull the NSS line low, and only then write or read data. This module wraps it, so that it presents itself like a regular SpiDevice to other places in the program.
Radio
One chonky puppy! Every low-level operation, like setting the frequency, setting register flags, calibrating the radio, absolutely everything that a SX1262 radio can do on a very low level. It also keeps track of a RadioState and validates transitions, so that it's not possible to eg. transmit while the radio is asleep. This module also hosts the RF switch, which has to be configured by the user for their board and passed here.
Configure, Transmit, Receive traits
Three cool traits! Configure has an associated Config type, so each modulation defines its own config struct. Transmit takes a byte slice and sends it, Receive takes a buffer and a timeout and returns how many bytes were received. It's helpful for writing generic code over various modulations!
Modulation radios (LoraRadio, FskRadio, MskRadio, BpskRadio)
It's what the user is actually interacting with. At first only LoraRadio was implemented, but then I implemented the rest, one by one - BpskRadio, MskRadio and FskRadio.
Each one borrows a Radio rather than owning it, so that I can do this for example:
// configure the `Radio` once here
// use the `Radio` for lora
let mut lora = LoraRadio::new(&mut radio);
lora.configure(&lora_config).await?;
lora.tx(b"lora(wr) >:3").await?;
// after lora.tx finishes, lora goes out of scope (the borrow ends)
// same `Radio`, different modulation!
let mut fsk = FskRadio::new(&mut radio);
fsk.configure(&fsk_config).await?;
fsk.tx(b"fsk??? wtf???").await?;
And this doesn't compile:
let mut bpsk = BpskRadio::new(&mut radio);
let mut fsk = FskRadio::new(&mut radio);
bpsk.configure(&BpskConfig {
frequency: 868_100_000,
bitrate: Bitrate::Bps600,
pa: PaSelection::HighPower,
power_dbm: 22,
..Default::default()
})
.await
.unwrap();
/*
error[E0499]: cannot borrow `radio` as mutable more than once at a time
--> examples/stm32wle5jc/src/bin/bpsk_tx.rs:37:33
|
36 | let mut bpsk = BpskRadio::new(&mut radio);
| ---------- first mutable borrow occurs here
37 | let mut fsk = FskRadio::new(&mut radio);
| ^^^^^^^^^^ second mutable borrow occurs here
38 | bpsk.configure(&BpskConfig {
| ---- first borrow later used here
*/
Which is awesome, because the compiler enforces that radio can only be used by one modulation at a time. No need for runtime checks!
Each one of these modulation radios have their own config struct, eg. LoraRadio has the typical LoRa stuff like spreading factor or coding rate, FskRadio has bitrate, frequency deviation, sync words, CRC type, whitening, MskRadio is basically FSK (more on that later!!) but calculaces freq dev automatically, and BpskRadio is the simplest one - contains only frequency and bitrate. All of them also have the Power Amplifier settings too!
RadioError
A shared error enum for the whole crate. Contains errors for all types of events, such as errors in SPI comms, in the radio itself or encountered when validating the user-provided config. Nothing fancy, just keeps things tidy and consistent.
BPSK!!!!!!!!
After everything was neatly in place, I decided to implement second modulation type. I really wanted to test out BPSK (binary phase shift keying), because it was the one modulation type I was sure nobody else got working in their Rust crates. I consulted the beloved RM0461 documentation and jumped to sections such as "4.9.3 Basic sequence for BPSK transmit operation", "BPSK Set_ModulationParams() command" and "BPSK Set_PacketParams() command".
Adding a new radio type proved to be easy af, since I had a solid, abstracted out foundation in place. Most of the stuff was identical to LoRa TX implementation, I just had to provide different modulation params (only bitrate, which was calculated from a formula taken from the reference manual, and a hardcoded 0x16, meaning a BT 0.5 Gaussian filter). I tested it and it worked on the first try!
The waterfall looks weird tbh and I haven't tested demodulating it on my computer via a SDR. It might send correct data, it might not, it's a subject for further verification. One thing I know for a fact is that the modulation provided by the chip only allows sending raw data, without any framing. The framing (according to the manual - preamble, sync word, device ID and CRC) has to be provided by the user (and I'm not doing shit, just sending raw bytes). In the next part I'll try using a SECOND STM32WLE5JC to receive LoRa, FSK and MSK and I might try using my RTL-SDR to demodulate the BPSK data. Well, I tried, but GNU Radio is so unbelievably fucking complex that I gave up quickly. Next time, next time.
MSK
The next modulation type on my list was MSK (minimum-shift keying). I was sure implementing it will be as easy as implementing BPSK, but hell no. The first issue was that according to RM0461, byte 0x03 is a Msk PacketType, but according to the SX1261/2 docs, 0x03 meant Long Range FHSS. Two completely different things, but maybe it's not really a problem and they really have changed the packet type there?
I followed the same steps as with BPSK: reading the reference manual, reconstructing how the sequence should look like, and...
I had no idea what that is. The tail end looked correct, but there was a super long time at the start of the transmission with only, I assume, a carrier. I didn't try to demodulate it (arghhhhh I gotta learn how to use GNU Radio), but it just looked wrong and weird. I digged deeper to see what can I do, and found out an interesting thing!
MSK is actually a special case of FSK: it happens when you have a FSK modulation and its parameters are chosen in a way that the modulation index is 0.5, which causes the waveforms of 0s and 1s to be orthogonal to each other. When transmitting at 600 bps, you can calculate the necessary freq deviation like so:
h (modulation index) = 0.5
h = 2 * Fdev / bitrate
|
v
Fdev = bitrate / 4
Fdev = 600 / 4 = 150 Hz
By using this setting, the modulation becomes MSK, ASSUMING the original FSK modulation is not fucked up and is a proper continuous-phase frequency-shift keyed (CPFSK) modulation. So oh well, I just assumed it's that. I got a very pretty, narrow band, without the typical two left and right peaks of FSK signal:
Being a uhhhh professional, certified RF equipment operator, I didn't even think of opening the cursed GNU Radio, instead I looked up MSK signals on sigidwiki. It looked very similar to what I saw there. No frequency jumps like in FSK, narrow band, etc.
Implementing the Receive trait was easy, I just did similar things that were already implemented in LoraRadio. I tested it quickly, it worked, waited until timeout (because ofc I didn't have a way to actually receive anything). When I get the second STM32WLE5JC (tomorrow!!!) I'll test RX. If it fails, I'll jump to GNU Radio. If THAT fails, I'll consider fucking around with the weird 0x03 mode.
FSK
FSK (frequency shift keying) was the last type of modulation to implement! Actually, I implemented both FSK and MSK at the same time, because it turned out MSK had to depend on FSK. MSK even reexports some types because they're 1:1 the same. Sooooo, not much to say here, other than the fact that it was easy and I didn't encounter any problems. Look at this pretty signal! + on non-MSK settings it looks totally different yay.
Conclusion
In the meantime, I restructured the crate so that it could be published (in some time) on crates.io! I added easy to follow examples too, just look at this abstracted out beauty :>
#![no_std]
#![no_main]
use defmt::{error, info};
use embassy_executor::Spawner;
use embassy_stm32::{
Config,
gpio::{Level, Output, Speed},
rcc::{MSIRange, Sysclk, mux},
spi::Spi,
};
use stm32wl_subghz::{
Configure, PaSelection, Radio, SubGhzSpiDevice, Transmit,
modulations::lora::{Bandwidth, LoraConfig, LoraRadio, SpreadingFactor},
};
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let mut config = Config::default();
{
config.rcc.msi = Some(MSIRange::RANGE48M);
config.rcc.sys = Sysclk::MSI;
config.rcc.mux.rngsel = mux::Rngsel::MSI;
config.enable_debug_during_sleep = true;
}
let p = embassy_stm32::init(config);
let spi = SubGhzSpiDevice(Spi::new_subghz(p.SUBGHZSPI, p.DMA1_CH1, p.DMA1_CH2));
let rf_tx = Output::new(p.PC4, Level::Low, Speed::High);
let rf_rx = Output::new(p.PC5, Level::Low, Speed::High);
let rf_en = Output::new(p.PC3, Level::Low, Speed::High);
let mut radio = Radio::new(spi, rf_tx, rf_rx, rf_en);
radio.init().await.unwrap();
let mut lora = LoraRadio::new(&mut radio);
lora.configure(&LoraConfig {
frequency: 868_100_000,
sf: SpreadingFactor::SF9,
bw: Bandwidth::Bw20_83kHz,
pa: PaSelection::HighPower,
power_dbm: 22,
..Default::default()
})
.await
.unwrap();
info!("sending lora stuffs");
match lora.tx(b"hiiiii hello :3 :3 :3 this is a looooooooooooooooong text! very long :> and cute! :3 :3 :3 :3 :3 :3 :3 :3 :3 :3 :3 :3 :3 :3 ummmm urghhh awwwooooooo woof wooooof woof").await {
Ok(_) => info!("yay tx done :3"),
Err(e) => error!("tx error: {:?}", e),
}
}
That's EXACTLY what I wanted to achieve and didn't believe at first I'll succeed! But I did. I'm really happy and I can't wait for the second board to arrive to test the RX aspect of my crate. After that, who knows what I'll do with these boards... I have several ideas, such as TUN/TAP driver for long range internet access, or using these various modulations to send images and telemetry from a stratospheric baloon (I wanna do it SO BADLY). But it's a long road, I want to papmer my cutie crate so much more. There's so much to do! Eg.
- testing EVERY modulation type and config, both in TX and RX modes
- learning GNU Radio
- being compliant with international radio norms (I'm a BAD, BAD GIRL now, ignoring EU's radio transmission laws!!)
- publishing the crate
- maybe testing on other STM32WL boards
Such a cool project, which is available here :>
Huggies and kissies :*