lora-coverage-tester+

A LoRa Coverage Field Tester Built from Two Off-the-Shelf Mesh Nodes

● 2026-08-28 · 14 min readdraft

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.

  1. Keep Meshtastic — ruled out: it fundamentally doesn’t speak LoRaWAN.
  2. 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.
  3. PlatformIO + Arduino (Adafruit nRF52 BSP) + RadioLib — chosen. Mature BLE (Bluefruit) and display libraries, a reproducible build, one pio run for 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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-E 0.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.