"The last post" and "the first post" in the LoRa, Gemini and S1 posts are now links. check_site.py follows every link to another page of the site and the #fragment it names, so a broken one fails the Site job. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
18 KiB
+++ title = '''The loudest thing it hears is itself''' description = '''roro9stack switches on its LoRa radio, receive only: a Radio Service that shares the SD card's bus, a Scanner that decodes Meshtastic headers and records Wireshark captures, and a Sweep of the whole band. It works, and in an hour outside it heard exactly nothing, mostly over the noise of its own electronics.''' date = 2026-10-05T21:00:00+02:00
[extra] topics = '''ESP32-S3 · LoRa · Meshtastic''' read_label = '''Read what it's listening to →''' uid = '''radio: 0 packets, 0 bad CRC, 0 radio errors''' dek = "Milestone three for roro9stack, my firmware for the M5Stack Cardputer: the LoRa radio on its Cap, the one this whole project was bought for. This milestone only listens; the mesh comes after. The radio works, the Scanner works, the captures open in Wireshark. What it has heard, after an hour outside, is the noise of the Cardputer's own electronics, which turns out to be louder than anything else in Brussels." byline = '''designed by interrogation, round five: 16 questions, and a milestone I cut in half before writing a line'''
[extra.sign] label = "Packets received from another device" note = "After an hour outside. Still zero." count = "0" tone = "red"
extra.cast name = "The radio" role = "Semtech SX1262 on the Cap LoRa-1262" text = "868 to 923 MHz, a 1.8 V temperature-compensated crystal, an RP-SMA antenna. It identifies itself as an SX1261, which every SX1262 does. Nobody knows why; everybody's code checks for it anyway."
extra.cast name = "The expander" role = "PI4IOE5V6408 at I2C 0x43" text = "A tiny I/O chip on the Cap with one job that matters: its pin P0 connects the antenna. At power-on it doesn't. The project that supports this exact hardware never mentions it."
extra.cast name = "The neighbours" role = "6 Meshtastic nodes within 30 km" text = "According to meshmap.net, with positions blurred by a few kilometres on purpose. Two within 10 km, the nearest about 5 km east. None of them has been heard from yet."
extra.cast name = "The noise" role = "about 15 dB of it, home-made" text = "Broadband, flat across the band, with a comb of steady spikes on top. It followed the Cardputer outside, onto the battery, and mostly stayed when Wi-Fi went off." +++
TL;DR
- The radio works, receive only. A Radio Service owns the SX1262 on its own task and shares the SPI bus with the SD card. Nothing in the firmware can transmit: the function doesn't exist.
- The antenna needs switching on. P0 of an I/O expander on the Cap connects it. Without that the receiver is deaf, reading a flat -112 dBm. Meshtastic's board file for this hardware doesn't mention it; M5Stack's page does.
- A LoRa Scanner App:
- The Sniffer lists packets and decodes the Meshtastic header, which is never encrypted.
- Captures are pcap files with LoRaTap headers, and Wireshark reads them field by field.
- Sweep draws the whole 863–870 MHz band as bars and a waterfall.
- Testing found two older bugs, both fixed:
- The Debug Console's file upload could silently write 3 KB of zeros to the card.
- The Gemini client left less memory than it promised on big pages.
- It heard nothing. Twenty minutes at the desk on Meshtastic's channel and three LoRaWAN frequencies, then an hour outside on battery: zero packets and zero headers. The noise floor is about 15 dB above what the chip hears on its own, and Sweep says most of that is the Cardputer itself.
- Tagged v0.6.0; the code is on my Gitea, and the plan with every measurement is docs/milestones/M3.md.
Why it only listens
The plan from the first post put the mesh last: receiving in M4, transmitting in M5, and before that, a second Meshtastic device to test against. M3 is the radio coming up: drivers, the shared bus, a scanner. It needs nothing to talk to, which is lucky, because I have nothing to talk to.
I checked anyway. meshmap.net publishes the nodes that report to the internet. I downloaded the whole list and filtered it on my own machine, so my position stayed home. Within 10 km of my desk there are two nodes; within 30 km, six, all seen that day. The map blurs positions by a few kilometres, and it only shows nodes that report online, so there are probably more. Whether any of them reaches a small antenna in a city is another question. Spoiler: no.
The cast
{{ cast() }}
Designed by interrogation, round five
Sixteen questions, Q89 to Q104. The first one cut the milestone in half. The original M3 also had Notes and a file browser. Since then, the file browser had grown into a proper design of its own, in the project's issue tracker. So M3 became the radio only, and the other two moved out to issues #3 and #19 for later. The radio is the risky part, and nothing else in the milestone needed it.
The rest settled how it would work:
- a Radio Service that owns the chip;
- Meshtastic's LongFast settings by default (869.525 MHz, 250 kHz, spreading factor 11);
- the Meshtastic header decoded, but not the encrypted payload;
- captures in a format Wireshark reads;
- a Sweep that pauses the Sniffer while it runs.
One decision mattered more than the others. The Radio Service has no transmit function. It isn't unused: it doesn't exist. Nothing in M3 can put this device on the air by accident, because there's no code for it to do so with. Safety by absence, the only kind that survives a tired developer at 1 am.
The antenna that wasn't
Step one is always a probe: a console command that asks the hardware what it is before any real code trusts it. lora probe found the chip on the pins Meshtastic uses. The chip identified itself as SX1261 V2D 2D02, which is what every SX1262 says. Its 1.8 V crystal worked on the first try, and it was ready in 38 ms.
Then it measured how much noise it could hear, and that was suspiciously little: a flat -111.9 dBm. That's the sound of a receiver listening to itself.
The two sources disagreed:
- Meshtastic's board file says the radio drives its own antenna switch through its DIO2 pin, and that's all.
- M5Stack's page says the switch is enabled by pin P0 of an I/O expander on the Cap's I2C bus. It gives no address for the expander.
The probe scanned the bus and found the expander at 0x43, its pin P0 configured as an input, so not driving anything. With P0 set high, the noise jumped to -87 to -94 dBm: the antenna, finally hearing the room. DIO2 made no difference to reception either way; it most likely picks transmit or receive inside the switch.
So the Radio Service sets P0 high at boot. Without it, this radio would have listened politely to its own thoughts forever, and I'd have blamed the neighbours.
One task to own it
{{ diagram(src="radio.svg", min_width=600, caption="Who touches what. The radio task is the only code that talks to the chip; the main loop only asks. The card and the radio share one SPI bus and one lock. The expander sits on the I2C bus with the keyboard, so only the main loop touches it.") }}
The radio is shared, slow to talk to, and interrupt-driven, all at once. So it gets one owner. The radio task does every SPI transfer to the chip, under the same bus lock the SD card uses. The radio's interrupt line (DIO1) only wakes that task; nothing happens in the interrupt itself. The main loop posts requests ("listen", "use this preset", "sweep") and reads the packets the task leaves in a ring of 32.
That ring takes 9.8 KB, and only while something is listening. When nobody is, the radio sleeps and the memory goes back. RadioLib and all of this cost 24 KB of flash.
The I/O expander is on the I2C bus that the keyboard also uses. Rather than share a bus between tasks, everything on it stays on the main loop. One owner per bus. I learned that rule the hard way in M2 and didn't fancy learning it twice.
The bus test that caught someone else
The risk of sharing a bus is that two users corrupt each other's traffic. So the test was a 1.7 MB file uploaded to the card over Wi-Fi while the radio listened, then read back and compared.
It differed. Bytes 188,416 to 191,487, exactly six 512-byte sectors, had come back as zeros.
Blaming the radio would have been easy. But the same test with the radio asleep failed too, in a different place. And the console's log had the answer: put: write failed at 191488, retry 1.
Here's what happened:
- The card occasionally refuses a write.
- The upload code's retry closed the file, which threw away the last 3 KB still sitting in the write buffer.
- It then truncated the file up to the length it thought it had written. Truncating a file to a larger size fills the gap with zeros.
- The SHA-256 check covered the bytes received over the network, not what landed on the card, so it passed.
The checksum was checking the postman, not the letterbox. This bug came from the OTA milestone and had been waiting for someone to look.
Now the upload reads the file back from the card and hashes it, and a retry that finds lost data gives up instead of papering over it. The card still refuses a write about once in five 1.7 MB uploads, with the radio listening or asleep. With the radio listening, four uploads in a row came back intact.
The same test also caught the Gemini client leaving 1.5 to 3 KB less memory than its 40 KB promise on large pages, radio or no radio. Its budget counted the page text and forgot the App's own tables. That's fixed too.
Nothing to hear
With the radio up and the bus proven, I listened for twenty minutes:
- On LongFast.
- On the three frequencies LoRaWAN sensors use (868.1, 868.3 and 868.5 MHz), at spreading factors 7, 9 and 12. Brussels is full of those sensors, and I hoped one would at least wave.
Zero packets. Zero headers, valid or broken.
The radio also flags "preamble detected" when something looks like the start of a packet. It flagged 25 in two minutes on LongFast, which looked hopeful. So I ran a control on 869.0 MHz, where nobody transmits LoRa: 97 detections in two minutes. False alarms, then. Noise that happens to look like a preamble.
Proving the ears work without a voice
Zero is a suspicious number. If the interrupt never reached the task, the result would look exactly like this.
The chip can prove its own interrupt without any transmitter: start a receive with a 100 ms timeout, route the timeout to DIO1, and see whether the task wakes. It woke after 105 ms. So the antenna, the radio, the interrupt and the task are all fine, and the silence is real.
To test everything after the radio, Debug Builds got lora inject. It puts a made-up packet into the ring as if it had been received, and nothing goes on the air. That made the App and the captures testable.
A capture made on the device opens in TShark with every field right: time, frequency, spreading factor, RSSI, SNR and payload. One detail took a second look. The LoRaTap spec says packet RSSI below 0 dB SNR is stored in quarter dB, a formula from older chips. Wireshark ignores that rule and reads plain dBm. I went with Wireshark, since that's who reads the files.
The Scanner
{{ figure(src="scanner.png", alt="Four Cardputer screens at 2x in a grid. Top left: the LoRa Scanner listening on LongFast 869.525 MHz, noise -97, with No packets yet and Meshtastic nodes in range show here. Top right: three test packets injected from the console, newest first, with time, RSSI and SNR, the first a 3-byte packet, the second d3c4>0b0a 1/3 and the third 5678>all 0/3, CAP 3 at the top right and CAP in the Status Bar. Bottom left: the details of the second packet: 20:14:39, 20 bytes, -117 dBm, SNR -9.2 dB, noise -85, 869.5250 MHz, then From !a1b2d3c4 to !0d0c0b0a, Packet 04030201, wants an ack, Hops 1 of 3, limit 2 left, Channel 0x08 (LongFast, default key), Relayed by ..aa, and the first line of the hex dump. Bottom right: Sweep at the desk, bars across 863 to 870 MHz with peak-hold dots and a dotted line at 869.525 MHz, a mostly blue waterfall with a brighter column near 863.2 MHz, and floor -100, 863.2 MHz at -89 dBm", width=976, height=556, landscape=true, full=true, caption=The Scanner: listening, a list (of packets I injected from the console, since the real world hasn't sent any), one packet's details with its Meshtastic header, and Sweep at the desk.) }}
The Sniffer lists packets newest first:
- time, RSSI and SNR on every row;
- for Meshtastic packets, also who sent it to whom, by the last four hex digits Meshtastic uses as a default name, and how many hops it took.
Enter shows the details: the full node numbers, the packet ID, whether it wants an acknowledgement, the hops, the channel (hash 0x08 is LongFast with the public default key), the node that relayed it, and a hex dump. p picks one of the seven Meshtastic presets allowed in Europe. c starts a capture, which keeps recording after you leave the App.
Sweep, and the loudest thing in the room
Sweep steps across 863–870 MHz in 100 kHz steps and reads the signal strength at each, a full pass every 0.6 seconds. At the desk, the band came back flat at -100 to -102 dBm, with a steady carrier at 863.2 MHz. Flat noise across the band is the signature of digital electronics nearby, not of a transmitter. My desk has plenty of candidates, so I asked for the Cardputer to be taken outside, on battery.
{{ diagram(src="sweep-outside.svg", min_width=600, caption="One Sweep pass outside, on battery. The same spikes come back on every pass, several of them 400 kHz apart. The one at 869.4 MHz sits right on the lower edge of LongFast's channel.") }}
Outside wasn't quieter. It was slightly louder, and the spikes were still there, in the same places, pass after pass. A noise source that follows the device outside and onto its battery is in the device.
Then Wi-Fi went off for a minute, read from the screen since my connection went with it: -99 to -100 dBm, against -97 with it on. Two or three decibels, no more.
{{ diagram(src="noise.svg", min_width=600, caption="What the radio hears with nothing transmitting, all at 125 kHz. The antenna-off figure was measured at 250 kHz and scaled.") }}
So about 15 dB of the noise floor is the Cardputer's own: the processor, the display, the SD card, its power supply, all within a few centimetres of the antenna. One suspect is the radio itself: the spike at 864.0 MHz is exactly the 27th harmonic of its own 32 MHz crystal. A suspect, not a verdict.
What 15 dB costs: LoRa at spreading factor 11 decodes down to about 17 dB below the noise floor. On this Cardputer that's roughly -114 dBm, against about -130 for a quiet receiver. In a city that divides the range by something like three to five. A node across the street will get through; one 5 km away, through Brussels, probably won't.
The hour outside
From 20:33 to 21:34 the Cardputer sat outside on its battery, listening on LongFast with a capture running, while I checked on it every five minutes over Wi-Fi.
Zero packets. Zero headers. Zero radio errors. The noise stayed at -85 to -87 dBm for the whole hour, the preamble detector cried wolf 860 times, and the capture file ended at 24 bytes: a pcap header, and nothing to put after it. The firmware, at least, didn't blink: no restart, 93 KB free.
The design round had a rule for this (Q104): if the hour hears nothing, M3 closes anyway, and a Meshtastic node of my own becomes a requirement for the next milestone. So that's what happens. One item on the checklist stays half done, and I'd rather say so than tick it: Sweep was never pointed at a known transmitter, like a car key. It shows the floor and the Cardputer's own spikes; nobody has keyed a real signal in front of it yet.
Where it stands
{% steps() %}
-
Step 1: the probe, and the antenna that needed switching on. -
Step 2: the Meshtastic header, presets and captures, tested on the PC against Meshtastic's own source and TShark. -
Step 3: the Radio Service, and the bus test that found the upload bug. -
Step 4: the Scanner App, with captures that outlive it. -
Step 5: Sweep, and finding out who's making all that noise. -
Step 6: an hour of listening, outside. It heard nothing.v0.6.0. -
Next, M4: receiving the mesh. It needs a Meshtastic node of my own, sitting a metre away, where even this receiver can't miss it. The self-noise has an issue of its own (#20): switch things off one at a time, Sweep, and find out which part of the Cardputer is shouting. {% end %}
By the numbers
{% table() %}
| Design questions | 16 (Q89 to Q104) |
| Tests | 365, 27 of them new |
| Flash cost of the radio, Scanner and Sweep | about 52 KB (1.72 MB of 3.3 MB) |
| RAM while listening | 9.8 KB (the packet ring), nothing when idle |
| Radio task stack, peak | 2.0 KB |
| A Sweep pass, 863–870 MHz | 71 steps, 0.6 s |
| Noise floor at 125 kHz: chip alone / desk / outside | about -115 / -101 / -97 dBm |
| Zeros the upload bug wrote | 3,072 bytes, now none |
| Free memory with the radio listening and IRC connected | 52.6 KB, above the 40 KB floor |
| The listening hour | 61 minutes, 860 false alarms |
| Packets from another device | 0 |
| {% end %} |
{% signoff() %} The radio is up, it listens, it records, it draws the band in colour. All it needs now is someone to say something, louder than it talks to itself. {% end %}