Listening to a Flipper Zero with an RTL-SDR
Disclaimer: co-written with Claude. This type of post is for education, and Claude is a great teacher here.
The Flipper Zero has a little-known feature hidden in its USB command line: subghz chat. Two Flippers on the same frequency can type messages to each other over the air. I only have one Flipper, but I do have a 25€ RTL-SDR dongle. So the question of the day is: can we read the Flipper's messages with nothing but a cheap SDR and some numpy ?
Spoiler: yes. Every figure in this post comes from a single real recording: 6 seconds of raw I/Q while the Flipper sends one line of text, and we'll decode it step by step.
R1kori7h: Hello from the Flipper, decoded by an RTL-SDR!What is on the air
Under the hood, the Flipper uses a CC1101 transceiver. For the chat, the firmware loads a preset (GFSK9_99KbAsync), and from its register values we can work out everything the radio does:
- Modulation: GFSK (Gaussian Frequency Shift Keying). A
1shifts the carrier up, a0shifts it down. - Deviation: $\Delta f = \pm 19.04\ \text{kHz}$ (register
DEVIATN = 0x34). - Bit rate: $R_b = \frac{(256 + 147) \cdot 2^{8}}{2^{28}} \cdot 26\ \text{MHz} = 9992.6\ \text{bit/s}$ (registers
MDMCFG4/3). - Framing: the CC1101 builds the packet in hardware: a preamble, a sync word, a length byte, the data, and a CRC.
AA AA AA AA | sync 46 4C | LEN | DATA[1..60] | CRC-16That's the whole spec. Now let's see whether the real signal agrees with it.
Recording
The dongle samples at $f_s = 1.024\ \text{MS/s}$ and outputs 8-bit unsigned I/Q. I don't tune it right on the Flipper's $433.92\ \text{MHz}$, but $250\ \text{kHz}$ below it, at $433.67\ \text{MHz}$. RTL-SDRs have a strong DC spike in the middle of their band (LO leakage and I/Q offsets), and we don't want our signal sitting on it.
rtl_sdr -f 433670000 -s 1024000 -g 20.7 -n 6144000 cap.cu8
The raw bytes are turned into complex samples centered on zero: $$x[n] = \frac{I[n] - 127.5}{127.5} + j,\frac{Q[n] - 127.5}{127.5}$$
Finding the packets
Most of these 6 seconds are noise. The cheapest way to find a transmission is to look at power over time: I average $|x[n]|^2$ over 1 ms blocks (1024 samples), estimate the noise floor as the 20th percentile of all blocks, and flag every block that is 10 dB above it.
Two bursts! The chat sends at most 60 bytes of text per packet, and our line is longer than that, so it was split in two. Packet 1 lasts 55 ms: $69\ \text{bytes} \times 8 / 9992.6 = 55.2\ \text{ms}$. The bit rate checks out.
Looking at the spectrum
A waterfall of the same 150 ms shows where the energy is in frequency.
The two packets sit $+250\ \text{kHz}$ above the tuned frequency, as planned, and the DC spike is a thin line at $0$ for the whole recording. Averaging the spectrum over the noise only and over packet 1 gives the PSD:
The signal is 20 to 30 dB above the noise floor in its own band. Zooming in around the carrier, we see the typical shape of FSK: two humps, one per tone.
The tones are $38\ \text{kHz}$ apart, as expected, but they are not centered on 0: the whole signal is shifted by about $+3.7\ \text{kHz}$. That's the carrier frequency offset (CFO): neither the Flipper's crystal nor the dongle's crystal is perfect, and at 433 MHz an error of 8.5 ppm is already 3.7 kHz. We will have to deal with that.
Down to baseband
First, we move the carrier to 0 Hz by multiplying with a complex exponential: $$b[n] = x[n] \cdot e^{-j 2\pi \frac{f_{\text{off}}}{f_s} n}, \quad f_{\text{off}} = 250\ \text{kHz}$$
Then we filter out everything that is not our channel. A simple boxcar (moving average) of 20 samples is enough. Its frequency response is a sinc with its first null at $f_s / 20 = 51.2\ \text{kHz}$: wide enough to keep both tones, narrow enough to remove most of the noise.
Notice the DC spike, now at $-250\ \text{kHz}$ after mixing, buried in the filter's sidelobes. This is why we tuned off-center.
Here is what the filtered signal looks like, during the preamble:
In a 1 bit the phasor turns one way, in a 0 bit it turns the other way. In the I/Q plane it's just a circle: the amplitude is constant, all the information is in how fast the phase rotates.
Instantaneous frequency
Speed of phase rotation is frequency. The simplest FM discriminator measures the phase change between two consecutive samples: $$\phi[n] = \arg\left(b[n] \cdot b^*[n-1]\right), \qquad f[n] = \frac{f_s}{2\pi},\phi[n]$$
No unwrap needed: at 1.024 MS/s and $\pm 19\ \text{kHz}$, the phase moves by at most $0.14\ \text{rad}$ per sample. I then average $f[n]$ over 0.6 bit to remove most of the noise.
The bits are right there, as two frequency levels. The levels are $+22.7$ and $-15.3\ \text{kHz}$: again the $\pm 19\ \text{kHz}$ deviation, plus the $3.7\ \text{kHz}$ offset. Also notice that the transitions are smooth: that's the G of GFSK, a Gaussian filter that shapes the frequency transitions to keep the spectrum narrow.
Look at the start of the packet, with a high-resolution spectrogram on top:
The preamble AA AA AA AA is 10101010..., the fastest pattern possible. Because of the Gaussian filter, the frequency never has time to reach the $\pm 19\ \text{kHz}$ levels: it only swings between $+19$ and $-11\ \text{kHz}$. After it comes the sync word, the length byte, and the data.
Finding the first bit
We know what we're looking at, but where exactly does the first data bit start ? The receiver needs bit timing, and the sync word is there for that. I build a template from the end of the preamble and the sync word, AA AA 46 4C as $\pm 1$ over 32 bits, and correlate it with the (offset-corrected) instantaneous frequency.
The peak marks the sync, with sample precision, and the data starts 32 bits after it. In the preamble, the correlation shows smaller periodic peaks: the template partly matches the preamble every 2 bits. The sync word was chosen to make the real peak stand out.
The preamble also gives the slicing threshold for free: AA has as many 1s as 0s, so the mean frequency over the preamble is the carrier offset. No need to estimate the CFO separately.
Slicing bits
From there, the receiver is trivial. The bit rate is known, so bit $k$ is centered at: $$t_k = t_{\text{data}} + \left(k + \tfrac{1}{2}\right)\frac{f_s}{R_b}$$ That's $102.5$ samples per bit. Each bit is the sign of $f[t_k] - f_{\text{CFO}}$. Is sampling in the middle of the bit a good idea ? An eye diagram answers that: we overlay every 2-bit window of the packet.
The eye is wide open. At the sampling instants, the 1s and 0s are at least $25\ \text{kHz}$ apart, and no sample comes close to the threshold. The traces are not single lines because of the Gaussian filter (a 1 between two 0s doesn't reach as high as a 1 in a run of 1s), not because of noise.
Let's look at the first bits after the sync:
0x3C is the length: 60 bytes of data. Then 0x1B 0x5B, which is ESC [, the start of an ANSI color escape code. The chat sends the Flipper's name in yellow!
Frames
Grouping the bits in bytes, using the length byte to know where the data ends, gives both packets:
The last two bytes are the CC1101's hardware CRC: CRC-16 with polynomial 0x8005, initial value 0xFFFF, computed over the length and data bytes.
def crc16(data):
crc = 0xFFFF
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x8005) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
Both match (07C7 and B4BD): every single bit was received correctly. Packet 1 ends in the middle of RTL-SDR and packet 2 finishes the line with \r\n. Joining them and removing the escape codes gives:
R1kori7h: Hello from the Flipper, decoded by an RTL-SDR!Wrapping up
The full receiver chain is short:
- Detect bursts with 1 ms block power, 10 dB above the noise floor.
- Mix the carrier from $+250\ \text{kHz}$ to 0 Hz, and filter with a 20-tap boxcar.
- Discriminate: $\arg(b[n],b^*[n-1])$, averaged over 0.6 bit.
- Synchronize by correlating with
AA AA 46 4C. The preamble gives the CFO. - Slice at bit centers, read the length byte, and check the CRC.
No PLL, no clock recovery loop, no equalizer: 55 ms is short enough that the bit clock doesn't drift, and with 20+ dB of SNR in the channel, the simplest tools work. The Flipper can't tell who's listening, and now an RTL-SDR on another computer can follow the whole conversation.