Summary
A wide burst near 433 MHz looked unusual in SDR++. It was too broad for a typical narrowband voice channel and displayed repeated slanted structures in the waterfall. That observation led to a passive research pipeline:
BladeRF / SDR++ capture
-> LoRa PHY identification
-> GNU Radio demodulation
-> payload CRC gate
-> packet-preserving UDP
-> Meshtastic protobuf decoder
-> JSONL and SQLite persistence
-> public, privacy-aware reporting
The goal of this note is to document the technical path from RF observation to structured Meshtastic packet analysis, while also showing that public RF telemetry can expose metadata, topology, and operational patterns.
Dataset scope and publication choices
This article treats Meshtastic traffic as public RF telemetry, but not as consent for permanent deanonymized publication. The published package therefore focuses on method, screenshots, aggregate statistics, scripts, and anonymized derived data.
Published here:
- SDR++ and GNU Radio screenshots.
- Signal, spectrum, spectrogram, and chirp-detail images.
- Public aggregate charts.
- An anonymized top-senders chart.
- Flowgraph and decoder scripts.
- Public CSV summaries derived from the capture.
- A redacted example packet structure.
Intentionally not included:
- Raw SQLite packet database.
- Raw JSONL packet capture.
- Full timestamped packet export.
- Exact node IDs, MAC-like identifiers, callsigns, and node names.
- Exact latitude/longitude records.
- Full logs containing raw runtime details.
Capture summary
| Metric | Result |
|---|---|
| Accepted packets | 736 |
| Collection duration | 3.81 hours |
| Unique sender IDs | 33 |
| Identified nodes | 11 |
| Position-reporting nodes | 28 |
| Unresolved accepted packets | 0 |
| Stored duplicates | 0 |
Packets marked via_mqtt | 683 |
No human text messages or alerts appeared in the strict collection. The traffic was mostly automated location, identity, and telemetry exchange.
1. Initial RF observation
The first waterfall showed repeated LoRa-like chirps in the 433 MHz range.

The first recording also showed that the channel was not captured cleanly at the recording boundary.

A full-recording spectrogram made the repeated chirp structure clearer.

The detailed view showed the characteristic slanted chirps.

Measured symbol timing pointed to LoRa with the following working parameters:
Bandwidth: 250 kHz
Spreading factor: 11
Coding rate: 4/5
Explicit header: yes
Payload CRC: yes
Sync word: 0x2B
Nominal centre: 433.125 MHz
The symbol period matched the expected relationship:
2^11 / 250000 = 0.008192 seconds
2. Capture configuration
The corrected SDR++ setup used a 2 MS/s baseband capture with the target channel offset from the hardware centre frequency.


Sample rate: 2 MS/s
Hardware centre: 433.270452 MHz
Target channel: 433.125 MHz
Gain: 50, manual
Recording: baseband IQ WAV, signed Int16
3. Offline GNU Radio demodulation
The first WAV flowgraph established the basic path from recorded IQ to LoRa demodulation.

The corrected WAV flowgraph used proper IQ handling, frequency translation, decimation, and LoRa receiver configuration.

Stereo WAV
-> Float to Complex
-> Frequency Xlating FIR Filter
-> LoRa Rx
firdes.low_pass(1.0, 2000000, 150000, 50000)
Frequency shift: -145452 Hz
Decimation: 2
Output rate: 1 MS/s
LoRa bandwidth: 250 kHz
The message output needed to be selected correctly so packet boundaries were preserved.

4. Live BladeRF reception
The next step replaced the WAV source with a live Soapy BladeRF source and forwarded packet PDUs toward the decoder.

One useful failure was a complex-to-byte mismatch: raw IQ cannot be treated as packet bytes before LoRa demodulation.

The final version gated packets by LoRa payload CRC before forwarding them over UDP.

5. Problems encountered and fixes
| Failure | Lesson |
|---|---|
| One WAV output | IQ WAVs need two channels mapped to real and imaginary. |
| Complex/byte type mismatch | Raw IQ must pass through LoRa demodulation before a byte-oriented block. |
| Scheduler requested 8200 samples but buffer held 8191 | High-SF receivers may need a larger minimum output buffer. |
GRC crashed on 0xff | Binary payloads must not be treated as UTF-8 terminal text. |
| Socket PDU remained silent | GNU Radio messages and PDUs are not automatically interchangeable. |
Sync word constructor rejected 43 | The installed receiver API required [0x2B]. |
| Random packets looked encrypted | CRC-invalid bytes can resemble valid protobuf fields. |
| Nested protobuf crashed the decoder | Validate both outer and application-specific messages. |
6. Decoder and persistence
The decoder consumed packet-preserving UDP output, parsed Meshtastic protobuf structures, wrote JSONL, and persisted normalized records in SQLite.
conda activate meshdecode
python ~/Downloads/meshtastic_udp_decoder_v2.py --audit-public-keys --jsonl ~/meshtastic/capture.jsonl --sqlite ~/meshtastic/capture.db --compact --suppress-exact-duplicates
The public package includes decoder scripts and the GNU Radio files, but does not include the raw database or raw JSONL capture.
- GNU Radio flowgraph
- Generated GNU Radio Python
- Main Meshtastic UDP decoder
- CRC PDU block
- Decoder audit helper
7. What the network exchanged
Application traffic
| Application | Packets | Share % |
|---|---|---|
| POSITION_APP | 624 | 84.8 |
| TELEMETRY_APP | 79 | 10.7 |
| NODEINFO_APP | 33 | 4.5 |

Origin flag
| Origin flag | Packets | Share % |
|---|---|---|
| via_mqtt = false | 53 | 7.2 |
| via_mqtt = true | 683 | 92.8 |

A packet marked via_mqtt was still received over LoRa by the SDR. The flag means the packet entered the wider system through an MQTT bridge earlier in its path.
Hop behaviour
| Hops used | Packets | Share % |
|---|---|---|
| 0 | 16 | 2.2 |
| 1 | 9 | 1.2 |
| 2 | 391 | 53.1 |
| 3 | 265 | 36 |
| 4 | 50 | 6.8 |
| 5 | 5 | 0.7 |

Sender concentration
The public version uses anonymized sender aliases. The point is not who the nodes are; the point is that passive collection can reveal concentration, centrality, and repeated activity.


Hardware distribution

Position source summary
| Position source | Nodes | Position packets | Distinct coordinates |
|---|---|---|---|
| LOC_EXTERNAL | 1 | 6 | 1 |
| LOC_INTERNAL | 5 | 18 | 12 |
| LOC_MANUAL | 22 | 600 | 22 |
The original position records contained exact coordinates. The public package includes only anonymized/rounded derived exports.
Telemetry metric summary
| Metric | Count | Minimum | Median | Maximum |
|---|---|---|---|---|
| device_metrics.air_util_tx | 72 | 0.0168889 | 0.731556 | 7.76292 |
| device_metrics.battery_level | 72 | 41 | 101 | 101 |
| device_metrics.channel_utilization | 72 | 0 | 6.1225 | 24.9733 |
| device_metrics.uptime_seconds | 72 | 60 | 4.84884e+06 | 1.15663e+07 |
| device_metrics.voltage | 72 | -0.001 | 3.9815 | 4.819 |
| environment_metrics.barometric_pressure | 7 | 1005.26 | 1005.87 | 1006.4 |
| environment_metrics.relative_humidity | 7 | 37.7549 | 43.8408 | 56.7432 |
| environment_metrics.temperature | 7 | 31.88 | 34.16 | 36.32 |
8. Pre-hardening versus strict capture
Before CRC gating and stricter semantic validation, corrupted RF frames could look like malformed or encrypted packets. After CRC gating, the accepted dataset contained no unresolved records.
| Metric | Pre-hardening result |
|---|---|
| Raw records | 2974 |
| Decoded plaintext | 2823 |
| Unresolved | 151 |

9. Visibility and privacy risk
The exploitation risk of this kind of traffic may be low in many hobbyist scenarios. The bigger issue is visibility: passive reception at scale can turn local RF transmissions into a durable dataset.
| Data point | Visibility risk |
|---|---|
| Node ID | Persistent tracking across captures. |
| Long name / short name | Possible attribution to a person, callsign, group, or location. |
| Position packets | Possible home, vehicle, hiking, event, or repeater location exposure. |
| Packet timestamps | Activity and presence patterns. |
| Hop metadata | Mesh topology and relay visibility. |
| Hardware model | Device fingerprinting. |
| MQTT flag | Evidence that traffic crossed from a wider bridge into local RF. |
| Repeated sender counts | Identifies central, active, or high-value nodes. |
In isolation, one packet may not reveal much. Over time, repeated passive collection can show which nodes are active, which nodes are central, whether positions are static or moving, and whether traffic is bridged through MQTT.
10. Public artifacts in this package
The package includes these public derived files:
- Capture summary
- Application packet summary
- Origin summary
- Hop summary
- Top senders, anonymized
- Identified nodes, anonymized
- Position exposure, anonymized and rounded
- Position source summary
- Telemetry metric summary
- Redacted example packet
The redacted example packet shows the structure without exposing raw identifiers or exact coordinates:
{
"event": "packet",
"from": "node_001",
"to": "broadcast",
"app": "POSITION_APP",
"decode_mode": "plaintext",
"key_assessment": "no-encryption",
"via_mqtt": true,
"hop_start": 7,
"hop_limit": 5,
"hops_used": 2,
"payload_fields_observed": [
"position.latitude",
"position.longitude",
"position.altitude",
"position.location_source",
"want_response"
],
"location_detail": "exact coordinates omitted in public package",
"packet_id": "redacted",
"channel_hash": "redacted"
}
11. Useful SQLite queries
These are representative queries used during the analysis. They are included for reproducibility, but the raw database is intentionally not published.
SELECT app, COUNT(*) AS packets
FROM packets
GROUP BY app
ORDER BY packets DESC;
SELECT hops_used, COUNT(*) AS packets
FROM packets
GROUP BY hops_used
ORDER BY hops_used;
SELECT
CASE via_mqtt
WHEN 1 THEN 'MQTT-originated'
ELSE 'RF-originated'
END AS origin,
COUNT(*) AS packets
FROM packets
GROUP BY via_mqtt;
SELECT
received_at,
sender,
packet_id,
channel_hash,
decode_mode,
json_extract(packet_json, '$.payload_hex') AS payload_hex
FROM packets
WHERE decode_mode = 'unresolved'
ORDER BY id DESC;
12. Practical mitigations for Meshtastic users
Users who care about privacy should consider:
- Disabling position broadcasting unless needed.
- Avoiding personally identifying node names.
- Avoiding callsigns or real names unless intentionally public.
- Understanding whether MQTT uplink/downlink is enabled.
- Limiting precision of shared location data.
- Using encryption settings appropriate to the use case.
- Treating public channels as observable by anyone with RF equipment.
13. Next improvements
- Store local receive power, SNR, and carrier-frequency offset.
- Add capture-session metadata.
- Keep CRC-failure counters without forwarding invalid payloads.
- Build topology summaries from neighbour reports and alternate paths.
- Test controlled nodes with plaintext, default PSK, and random AES keys.
- Keep exact third-party identifiers and coordinates out of public reports.
Conclusion
The central lesson was not just identifying a LoRa signal. It was building a trustworthy boundary between RF errors and protocol facts:
capture broadly
-> derive the PHY
-> validate the LoRa frame
-> preserve packet boundaries
-> parse defensively
-> store normalized data
-> separate payload confidentiality from metadata exposure
The result is a practical SDR research workflow and a concrete demonstration that public RF telemetry can create meaningful visibility risk even when direct exploitation risk is low.