We were asked to gather readings from a fleet of sensors in one place. The site was large, pulling cable to every sensor was impractical, and a per-device subscription fee was out of the question. We went with LoRaWAN: one gateway covers the area, and nodes read the sensors and push the values up.

There was one more condition. That gateway was live equipment with roughly forty other devices already attached to it. Global settings were off limits; we were only allowed to add an application and our own devices.

So the plan looked simple. Register the node, let it join, watch the data arrive. Half a day, we figured.

LoRaWAN join failure — nothing in the packet capture

A LoRaWAN join starts by registering the device and sending AT+JOIN=1 to the module. OK came back. And then nothing happened at all. On the gateway side, Last seen in the device list stayed empty.

We worked through the usual suspects first. Wrong band? Wrong keys? Antenna not seated? None of them.

So we ran a packet capture on the gateway. This is where things started to look strange.

Not a single packet from our module showed up. Not a “receive failure”, not even a CRC error.

During the same window, packets from dozens of other devices were arriving normally. Only ours were absent. There was no trace of a failure — it looked as if nothing had ever been transmitted.

We wrote the module off as defective — reflash and swap

The module firmware was our first suspect. What was on the module was an October 2021 build, and half the AT commands in the current documentation were unsupported. We downloaded the latest official UF2 and reflashed.

The “latest” build turned out to be dated one day after the original. Maintenance on this module family had effectively stopped at that point.

After the reflash the symptoms got worse: AT+JOIN returned an error immediately. This is where we wrote in our notes — “likely hardware failure.”

We swapped in a spare module. A different DevEUI, so definitely different hardware.

The symptoms were identical.

That is where we should have stopped. Instead we went with “maybe the spare is bad too.”

The real cause — the LoRaWAN Public toggle and the sync word

The gateway frequency plan settings were the next thing we went back through from scratch, expanding the details. There was a toggle sitting there.

LoRaWAN Public — OFF

The gateway was running in private mode. Our module, meanwhile, kept answering AT+STATUS with Public Network.

The LoRa physical layer carries a value called the sync word.

Modesync word
Public0x34
Private0x12

If that value differs, the receiver cannot even detect the preamble. It does not attempt demodulation and fail; the signal is treated as if it never existed. That is exactly why no CRC error was logged.

That one line explained every observation we had made.

  • Why two modules died identically → both were fine, and both shipped with the same default
  • Why the firmware, band, keys, and antenna were all correct yet nothing was received
  • Why dozens of other devices were fine at the same moment → they were all set to private

When the same symptom reproduces identically on two separate units, suspect “same default” before “both defective.” We went the other way, and it cost us a day.

Two ways to match the sync word, one of them forbidden

Matching the sync word left two options.

  1. Set our module to private — only our device changes
  2. Switch the gateway to public — one toggle and it is done

Option 2 looks overwhelmingly easier, but it would knock all forty-odd attached devices off the air. That is shutting down someone else’s equipment to bring up your own. We took option 1.

We pulled the firmware source and changed one line.

// main.h
bool public_network = true;   →   bool public_network = false;

We also confirmed in the source that the value actually reaches the stack (it is passed to enable_public_network in lorawan.cpp). We built with the arduino-cli bundled with the Arduino IDE; the UF2 is not generated automatically, so we converted it separately with the elf2uf2 included in the BSP. To check that the resulting UF2 was sound, we compared its header against the official UF2 — magic number, family ID, and start address all matched.

After flashing, the status read Private Network. Goal met.

Why AT+NJS stayed at 0 on a manual join

A manual join returned OK from AT+JOIN, but AT+NJS, which reports join state, would not budge off 0. The old firmware at least moved 2 (in progress) → 3 (failed); this time we were stuck at the starting line.

We suspected the library version and built against two of them, with identical results. We tried a debug build too, but the log flood over USB destabilized the port itself and got in the way more than it helped.

In the end we followed the source. The handler for the manual join signal looked like this.

if ((event.value.signals & SIGNAL_JOIN) == SIGNAL_JOIN)
{
    APP_LOG("APP", "Start Join");
    init_lora();          // ← LoRa P2P init function
    ...
}

init_lora() initializes LoRa P2P. It does not touch the LoRaWAN MAC. The function that brings up the LoRaWAN stack and actually requests a join is init_lorawan(), and that one was called only from setup(), and only when auto-join was enabled.

In other words, with auto-join disabled, a manual join never initializes the LoRaWAN MAC, so no join request goes out. Nothing is transmitted, so AT+NJS stays at 0 forever. The old firmware did not have this problem because it predated the P2P feature.

We changed two lines.

if (g_lorawan_settings.lorawan_enable) { init_lorawan(); } else { init_lora(); }

+NJS:1. The uplink came through.

The “defective” LoRa module was fine all along

The original module we had set aside as faulty got the same UF2.

It worked fine.

Both pieces of hardware had been healthy the whole time. Over two days we suspected, in order, the antenna, the firmware version, the hardware, the spare hardware, and the library version. The answer was one toggle on a gateway settings page and one function name in the upstream source.

A radio link that “fails” and a radio link that “appears not to exist” are different symptoms. When there is not even an error, the link is not weak — the two ends are speaking by different rules. At that point, stop measuring signal strength and start comparing defaults on both sides.

Total silence with not even a CRC error is not a link-quality problem — it means the two ends are running different sync words.


▶️ Next in this series — building a sensor without the hardware: With the radio finally working, we had no sensor to talk to. The real units were on site, so we wrote a program that impersonates a sensor on a PC.

Contact