A LoRa Coverage Field Tester Built from Two Off-the-Shelf Mesh Nodes
How, in two days, we turned a Heltec T114 and a Seeed T1000-E into a measurement tool, found the edge of the link at −108 dBm, and learned along the way that the walls of an apartment cost more than a kilometre of open field.
Why bother
We have our tracker products, all running LoRa on the Russian RU864 plan. Every conversation with a customer sooner or later comes down to the same question: “Will it reach?” The usual answer comes from a link budget calculator — with SF7 sensitivity of −123 dBm, free-space path loss, and the optimistic assumption that the antenna is pointing at the sky rather than sitting in a jacket pocket.
The problem is that reality differs from the calculator by tens of decibels, and not uniformly. We needed a tool that simply walks and measures: the operator walks around with the device, the phone draws the track, the points on the map are coloured by RSSI, and the edge of coverage is visible at a glance.
We set the key requirement right away and wrote it into an ADR: the tester must work without cellular service. Coverage gets measured where there is none — and often there’s no LTE there either. The only channel you can rely on in the field is the very radio channel you are measuring.
What we had on hand
We didn’t buy anything specially — we took what was already sitting on the shelf for other jobs.
| Device | SoC / radio | Has | Lacks |
|---|---|---|---|
| Heltec Mesh Node T114 | nRF52840 + SX1262 (+TCXO) | TFT ST7789 240×135, USB-C, LiPo, button | no GNSS |
| Seeed SenseCAP T1000-E | nRF52840 + LR1110 | GNSS (AG3335), NTC thermometer, light sensor, QMA6100P accelerometer, buzzer, IP65, ~700 mA·h | no screen, charging only via magnetic cable |
The pair turned out to be unusually well matched precisely because they complement each other: one has a screen but no positioning, the other has positioning and sensors but no screen. The common denominator is the same nRF52840, which means one toolchain, one BLE stack and one bootloader for both boards. Both ship from the factory with Meshtastic 2.7.15, and Meshtastic doesn’t do LoRaWAN — so the firmware had to change either way.
We already had a bit of groundwork: we had previously run RU864 on a RAK11721BB board (Ambiq Apollo3 Blue + SX1262, RUI3 stack) against a production ChirpStack, so the frequency plan and the duty-cycle pitfalls were known in advance. But none of that code carries over — different SoC family, different BSP, different flashing process.
The very first step was to export both Meshtastic configurations to YAML and commit them to the repository: the decision to reflash became reversible in five minutes via the web flasher.
Architecture: two test rigs in one firmware
We ended up with two measurement modes, built from a single main.cpp by
different PlatformIO environments.
Mode 1 — P2P ping/echo. No infrastructure at all: one device sends a ping, the other replies with an echo, and the echo carries the RSSI/SNR at which the ping was received. The tester also measures the RSSI/SNR of the echo itself — which gives you a two-way link measurement without a single gateway or server.
Ping 'G','P' : u16 seq, u16 batMv, i8 txPower, u8 rsvd (8 bytes)
Echo 'G','E' : u16 seq, i16 rssi10, i16 snr10 (8 bytes)
868.9 MHz / BW125 / SF7 / CR4-5 / preamble 8 / private sync 0x12
The responder replies after a 150 ms guard; the tester's receive window is 2.5 s
Mode 2 — LoRaWAN against a real ChirpStack. Here we no longer measure “node → node” but “node → real gateway”, i.e. the infrastructure that will actually ship to the customer. The gateway-side metrics live on the server, and the phone in the field can’t reach the server — so the data comes back over the air, down over LoRa:
iPhone ──BLE──▶ tester: GNSS coordinates + commands
tester ──uplink fPort 10──▶ gateway ──▶ ChirpStack ──MQTT──▶ echo service
echo service ──downlink queue──▶ ChirpStack ──▶ gateway ──LoRa──▶ tester
tester ──BLE notify──▶ iPhone: gateway RSSI/SNR, gateway coordinates, echo RSSI/SNR
The echo service is a single 300-line Python file (paho-mqtt + the ChirpStack
REST API): it subscribes to event/up, picks the best gateway by RSSI and
queues a downlink on fPort 11 that is exactly 13 bytes long.
| Offset | Type | Field | Meaning |
|---|---|---|---|
| 0 | int16 | gwRssi | RSSI of the best gateway, dBm |
| 2 | int8 | gwSnr | SNR of the best gateway, dB |
| 3 | uint8 | gwCnt | how many gateways heard the uplink |
| 4 | int32 | gwLat | gateway latitude × 1e7 |
| 8 | int32 | gwLon | gateway longitude × 1e7 |
| 12 | uint8 | gwIdHash | XOR of the eight bytes of gatewayId |
The phone computes the distance to the gateway itself (haversine) — no internet is needed at any step. A side effect, but a nice one: “the downlink didn’t arrive” is a measurement too. It means the uplink gets through, but the return channel no longer does.
Choosing the stack: why PlatformIO + RadioLib
We considered three options and recorded the decision in ADR-0001.
- Keep Meshtastic — ruled out: it fundamentally doesn’t speak LoRaWAN.
- Zephyr / nRF Connect SDK — the “proper” industrial path, with native LoRaMac-node including RU864 and Nordic’s own BLE. But there is no upstream board definition for the T114: we would have had to bring up the pins, the display and the flash partitions from scratch. For an internal tool, the cost of entry was out of proportion.
- PlatformIO + Arduino (Adafruit nRF52 BSP) + RadioLib — chosen. Mature
BLE (Bluefruit) and display libraries, a reproducible build, one
pio runfor everything.
The main trick, which saved us a day: the pin map was taken from Meshtastic’s variant files for this same board. They are correct by definition — the stock firmware that had just been running on our desk uses them. SX1262 on SPI0 (NSS P0.24, BUSY P0.17, DIO1 P0.20), TFT on SPI1 (the backlight is turned on by a low level on P0.15), peripheral power VEXT on P0.21 with a warm-up of about a second, battery ADC on P0.04 with a 4.916 divider and a 3.0 V reference.
The second discovery was less pleasant: RadioLib has no RU864. It has
EU868, US915, AS923 in all four variants, KR920, IN865 — but no Russia. We
checked before starting work, so there was no surprise: we defined our own
LoRaWANBand_t (a clone of EU868 from version 7.7.1 — join channels
868.9/869.1, RX2 on 869.1 DR0, range 864.0–870.0) in a separate firmware file.
The protocol as a separate artifact
By the evening of the first day it became clear that the interface was already
being implemented by three independent codebases: the T114 firmware, the
T1000-E firmware and the iOS app (later a fourth one joined — Android). Until
then the “contract” lived in a comment at the top of main.cpp and in the
app’s parser. For one device that works; for three, it silently drifts apart.
That is how the docs/ble-contract.md document came about, with its own
version and a table of implementations, and along with it three decisions
(ADR-0004):
Roles are interchangeable. Every device can act both as a tester (walks
around, sends pings, reports over BLE) and as a responder (stays put, replies
with an echo carrying the measured RSSI/SNR). The role is switched over BLE at
runtime and lives only in RAM. By default the T114 is the tester and the
T1000-E is the responder, but you can swap them from the app without reaching
for a soldering iron or a USB cable. The practical value: either of the two
devices can stay behind as the “base”, and either can go for a walk.
A capability bitmask. The DeviceInfo characteristic holds caps: GNSS,
temperature, light, accelerometer, buzzer, display, charge detection. On the
T114 it is 0x20 — display only. The app draws sensor tiles and the GNSS panel
only for what the device actually has, and the data fields carry explicit
“n/a” markers (0x7FFF for signed, 0xFF/0xFFFF for unsigned). There is not
a single if (model == T114) branch in the app.
Coordinate source priority. If the connected tester has a fresh fix (≥2D, age ≤10 s), the track points use its coordinates rather than the phone’s CoreLocation. The reason is mundane: a dedicated GNSS reports the satellite count and HDOP, while iOS never exposes them through the public API — only an accuracy radius.
The sizes of all structures in the firmware are locked down with
static_assert against the numbers in the contract. The build breaks, not the
radio link.
Pitfalls
The most useful part of any project like this is the list of things you wasted time on.
Entering DFU on the T1000-E. PlatformIO’s standard touch doesn’t work, the
Meshtastic CLI --enter-dfu times out, and holding the button while plugging
in does nothing. Exactly one combination works: open the CDC port at 1200
baud and hold DTR for about 1.5 s. The port dies mid-operation with Device not configured — that is the reboot. The board comes back with a different
VID:PID (the Seeed bootloader), and no UF2 drive gets mounted — serial DFU
only. An extra gotcha: in normal mode its USB product string is
T1000_E_BOOT, even though it is not the bootloader.
The LR1110 and the shared DIO1. On the T1000-E, after each of its own
transmissions, a phantom reception rx: non-ping frame len=0 would show up in
the log. The cause: the LR1110 uses the same DIO1 for both TX-done and RX-done;
the receive flag has to be cleared after your own transmission.
DevNonce. ChirpStack (LoRaWAN 1.0.4) rejects a reused DevNonce, and ours was reset to zero on every reboot. The fix is to persist it in InternalFS — after that the join succeeds on the first attempt in about 6 seconds, and the session survives both a reboot and a power-off via the button.
The MQTT marshaler. The echo service was written for JSON, but the live
ChirpStack was publishing protobuf (its default). The service now tries JSON
and, if that fails, parses protobuf via chirpstack-api.
iOS letterboxing. The app launched inside a black frame, as if it were an
iPhone 4. With a custom Info.plist, the INFOPLIST_KEY_UILaunchScreen_* keys
are silently ignored — you have to explicitly declare an empty
UILaunchScreen: {}.
USB port numbers move around. With two boards plugged in, flashing the
wrong device is one careless command away, so the ports in platformio.ini are
pinned explicitly, and the board should be identified via ioreg by its
product string, not by the port number.
Power-off via the button. Holding the user button for ≥3 s puts the
nRF52840 into System OFF. The final power: System OFF line never reaches
the host — USB dies first; the reliable sign of shutdown is the port
disappearing from the system. Waking up via the same button is a full restart.
Field test: July 2, evening
The first end-to-end run. The operator, with the T114 in a pocket (on LiPo) and an iPhone running the app, went out for a walk; the T1000-E stayed at home as the “base station” and, doubling up, as the source of a live log in the terminal.
| Parameter | Value |
|---|---|
| Channel | 868.9 MHz, BW125, SF7, CR4/5, private sync |
| T114 power | +10 dBm |
| Ping period | 15 s |
| Receiver | T1000-E (LR1110), indoors |
| Transmitter | T114 (SX1262), in the operator’s pocket |
| Firmware | T114 0.3.0-ble, T1000-E 0.2.0 |
| Event | seq | RSSI, dBm | SNR, dB |
|---|---|---|---|
| Baseline (desk, ~1 m) | 40–49 | −53…−59 | 13.5–14.5 |
| Leaving the apartment | 57 | −96 | 13.0 |
| Street, open area | 60–70 | −75…−94 | 13.5–14.8 |
| Degradation near the edge | 72 | −106 | 7.5 |
| Link lost | 73–99 | — | — |
| Silence | 419 s, 27 pings lost | ||
| Link restored | 100 | −110 | 3.5 |
| Edge (intermittent link) | 103–104 | −109 | 5.0 |
| Back in range | 105–108 | −102 → −62 | 10.5–14 |
What follows from this.
- The practical loss/recovery threshold is ≈ −108 ±3 dBm in this configuration. The weakest packet received was −110 dBm, i.e. roughly 13 dB above the theoretical SF7 sensitivity (≈ −123 dBm): the body, the pocket and the walls eat up all of the calculator’s nice margin.
- Walls cost more than distance. Leaving the apartment cut the level from −59 to −96 dBm in one go — in a single step. Distance in the open changed the level smoothly and much more slowly.
- The boundary is fuzzy and hysteretic: loss at −106, recovery at −110, and body and antenna position account for ±5 dB. At the edge the link is intermittent, with occasional dropouts.
- SNR is an early indicator. RSSI slid down smoothly, while SNR held at 13–15 almost everywhere and collapsed to 7.5 exactly one step before the link was lost. For predicting the edge, SNR is more informative than RSSI — arguably the main practical takeaway from the walk.
- The whole LoRa → echo → BLE → map in the app chain got through the walk without a single failure; the T114↔iPhone BLE link never dropped.
That same evening, after going through the log, contract v2 was born: roles, capabilities, sensors and GNSS over BLE. The app moved into its own repository, because it was no longer “an app for one board”.
ChirpStack day: July 3
The next day we assembled the second mode. The OTAA join on the custom RU864 succeeded on the first attempt, and BLE stayed connected throughout (the concern that the SoftDevice would wreck the RX1/RX2 window timing did not materialize). Then the network’s ADR kicked in and behaved exactly as it should: DR3 → DR5, power 16 → 2 dBm, channels spread across CFList 864.1–864.9.
The echo service answered 5 uplinks out of 5, and the echo made it into the RX1 window of the same cycle. We entered the gateway’s coordinates in the ChirpStack registry using a GNSS fix from the T1000-E itself — the device was lying next to the gateway, so that turned out to be the quickest way to get its coordinates.
It was also then that we learned something substantive about the tool itself: since the network chooses DR/SF, coverage maps taken with ADR enabled are not comparable with one another. We deliberately dropped our own “adaptive SF escalation”, which had been sitting in the backlog: in LoRaWAN mode that is rightfully ADR’s job, and comparable maps require a fixed SF for the whole experiment. This split — P2P mode for reproducible measurements with a fixed SF, LoRaWAN mode for measuring the real network as it is — turned out to matter more than any code.
By the evening an Android client for the same contract had appeared too, which made it possible to send out two operators at once: one with the T114 and an iPhone, the other with the T1000-E and an Android phone.
A commercial counterpart: RAK10701-P
Later a RAK10701-P Field Tester for LoRaWAN Pro landed on the desk — a factory-made embodiment of exactly this idea: a 320×240 touchscreen, GNSS, a 3200 mA·h battery, an enclosure. Inside is a WisBlock RAK4630, i.e. the same nRF52840 + SX1262 as in the T114, plus a u-blox ZOE-M8Q. Figures from it and from our pair are directly comparable at the silicon level. At the time of writing the device has been documented from its label and the vendor’s source code, but has not yet been powered on.
What’s interesting from an engineering point of view is something else — it measures a different thing. The RAK sends its GPS fix on FPort 1 and computes nothing about gateways on its own: all the RSSI values, the gateway count and the distance on its screen arrive via a downlink on FPort 2, which the server has to build. Without the server side, the screen shows zeros. Our P2P mode, by contrast, needs neither a gateway nor a server at all.
So the two tools answer different questions: “will the node reach my real network” (RAK) versus “what range does this pair of devices get in these conditions” (our pair). It’s worth running them side by side. And, nicely, teaching our echo service to reply with the six-byte FPort 2 payload is a couple of evenings’ work: it already runs exactly this MQTT-uplink → REST-downlink loop against the same ChirpStack.
Wrapping up
In two days, two off-the-shelf mesh nodes became a working measurement tool:
- two firmwares on one contract (today T114
0.6.0, T1000-E0.4.0), each with two radio modes; - iOS and Android apps with a map, an RSSI-coloured track and experiments that are stored in an on-device database and survive a restart;
- an echo service on ChirpStack;
- a versioned BLE and over-the-air contract that keeps four codebases in agreement;
- and, most importantly, a number: −108 ±3 dBm — the practical edge for SF7 and +10 dBm with the receiver indoors and the transmitter in a pocket.
In the backlog: GPX/CSV track export, adding the gateway coordinates directly to the LinkResult BLE structure (right now, in P2P mode, the app computes distance from a “base is here” point), an audit of iOS background modes, and measuring System OFF current with a microammeter.
A separate takeaway, not about radio: the most valuable part of the repository turned out to be not the firmware but two documents — the protocol contract and the pitfalls log. Firmware can be rewritten in an evening. Having to find out all over again that the T1000-E only enters DFU at 1200 baud with DTR held is something you never want to go through twice.
Internal identifiers have been omitted from this text: server addresses, DevEUI, EUI and gateway coordinates, serial numbers. All radio parameters, frame formats and measurement results are given as is.