LoRaWAN Deep Dive — Device Classes, Gateways & The Things Network Guide
In this tutorial, you'll learn about LoRaWAN Deep Dive. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
LoRaWAN is the MAC layer protocol that sits on top of LoRa physical radio modulation, defining how devices join the network, format frames, handle acknowledgments, schedule transmissions, and manage security end-to-end across gateways and network servers.
Why This Deep Dive
The LoRaWAN & LoRa overview covers the basics. This article goes deeper into the protocol mechanics — join procedures, MAC commands, frame formats, gateway packet routing, and the Adaptive Data Rate (ADR) algorithm. Understanding these details is essential for building reliable LoRaWAN deployments at scale. The Things Network (TTN) processes over 2.5 million LoRaWAN messages daily across 150,000+ gateways. DodaZIP's asset tracking platform uses TTN for global package location, relying on the Class A uplink mechanism for minimal battery consumption.
Plain-Language Explanation
Think of LoRaWAN like a postal system. LoRa radio is the truck that drives the package. LoRaWAN is the postal service — it defines the envelope format, the address, return receipt options, and whether the sender waits for a signature.
A device sends a message (uplink) and the postal service delivers it. The device can optionally request a return receipt (downlink). The system ensures only authorized senders can use the service (encryption). And the routing works even if the sender moves to a different city (roaming). The Things Network is like a universal postal service that anyone can join, with community-run post offices (gateways) spread across the world.
graph TD
subgraph "LoRaWAN Join Procedure"
ED[End Device] -->|Join Request
DevEUI + AppKey| GW[Gateway]
GW -->|UDP Packet Forward| NS[Network Server]
NS -->|Verify MIC
AES-128 CMAC| Auth{Valid?}
Auth -->|Yes| NS -->|Join Accept
DevAddr + NwkSKey + AppSKey| GW
GW -->|Radio| ED
ED -->|Store session keys| Active[Device Active]
Auth -->|No| Drop[Reject Device]
end
subgraph "Data Transmission"
Active -->|Uplink Frame| GW
GW -->|Metadata + Payload| NS
NS -->|Deduplicate| Dedup{Multiple
Gateways?}
Dedup -->|Yes| Merge[Keep best RSSI]
Dedup -->|No| Forward[Route to App Server]
Merge --> Forward
end
style NS fill:#9b59b6,color:#fff
style ED fill:#27ae60,color:#fff
style GW fill:#e67e22,color:#fff
Device Classes in Detail
Class A (All devices): After every uplink, the device opens two short receive Windows (RX1 at 1 second, RX2 at 2 seconds). The network can send a downlink in these Windows. Device sleeps between transmissions. Lowest power — ideal for battery sensors.
Class B (Beacon synchronized): Gateways periodically broadcast beacon frames. Devices synchronize to the beacon and open receive Windows at scheduled times (ping slots). Allows scheduled downlink without waiting for an uplink. Power consumption is higher than Class A.
Class C (Continuous listen): Device opens receive Windows continuously when not transmitting. Lowest latency downlink — the network can send a command at any time. Power consumption is highest. Used for mains-powered actuators.
Join Procedure
Devices join via Over-the-Air Activation (OTAA) or Activation by Personalization (ABP):
OTAA: Device sends Join Request containing DevEUI, AppEUI, and a random DevNonce. Network server verifies the MIC (Message Integrity Code) using the AppKey. On success, network sends Join Accept with DevAddr, NwkSKey, AppSKey, and the device's RX parameters. OTAA provides session key freshness and is recommended for all deployments.
ABP: Device is pre-configured with DevAddr, NwkSKey, and AppSKey. No join procedure needed. Faster startup but no key rotation. Risk if keys are compromised.
import hashlib, os, struct
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def generate_lorawan_keys(app_key: bytes, dev_nonce: bytes, join_nonce: bytes) -> dict:
# Derive NwkSKey and AppSKey from AppKey
# NwkSKey = aes128_encrypt(AppKey, 0x01 | JoinNonce | JoinEUI | DevNonce)
join_eui = bytes(8)
derivation = b'\x01' + join_nonce + join_eui + dev_nonce
cipher = Cipher(algorithms.AES(app_key), modes.ECB())
encryptor = cipher.encryptor()
nwk_skey = encryptor.update(derivation) + encryptor.finalize()
derivation2 = b'\x02' + join_nonce + join_eui + dev_nonce
cipher2 = Cipher(algorithms.AES(app_key), modes.ECB())
encryptor2 = cipher2.encryptor()
app_skey = encryptor2.update(derivation2) + encryptor2.finalize()
return {"NwkSKey": nwk_skey.hex(), "AppSKey": app_skey.hex()}
# Simulate OTAA join
app_key = bytes.fromhex("0123456789ABCDEF0123456789ABCDEF")
dev_nonce = os.urandom(2)
join_nonce = struct.pack(">I", 12345)[:3]
keys = generate_lorawan_keys(app_key, dev_nonce, join_nonce)
print(f"NwkSKey: {keys['NwkSKey']}")
print(f"AppSKey: {keys['AppSKey']}")
Expected output:
NwkSKey: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
AppSKey: f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5
Frame Format
LoRaWAN frames contain:
- MHDR: Message type (Confirmed/Unconfirmed Data, Join Request/Accept)
- DevAddr: 4-byte device address assigned during join
- FCtrl: Frame control (ACK bit, ADR bit, ADRACKReq, FOptsLen)
- FCnt: Frame counter (16-bit, increments each uplink)
- FOpts: MAC commands included in the frame header (max 15 bytes)
- FPort: Application port (1-223 for app data, 0 for MAC commands)
- FRMPayload: Encrypted application payload
- MIC: 4-byte message integrity code (AES-CMAC)
MAC Commands
The network server and device exchange MAC commands in the FOpts field or as a separate FRMPayload on port 0:
- LinkADRReq/Ans: Network requests device to change data rate, TX power, or channel mask. Device confirms.
- DutyCycleReq/Ans: Network sets duty cycle limits. Device limits transmission frequency.
- RXParamSetupReq/Ans: Configure RX1 delay, RX2 data rate and frequency.
- DevStatusReq/Ans: Network requests device status (battery level, demodulation margin).
- NewChannelReq/Ans: Configure additional channels.
Adaptive Data Rate (ADR)
ADR optimizes data rate and TX power based on signal quality. The network server monitors received RSSI and SNR, then sends LinkADRReq to adjust:
class ADRController:
def __init__(self, min_snr: float = -5.0, target_snr: float = 10.0):
self.data_rate = 5 # Start at SF7 (fastest)
self.tx_power = 14 # dBm
self.min_snr = min_snr
self.target_snr = target_snr
def process_uplink(self, rssi: float, snr: float):
# ADR algorithm: if SNR is high, try faster data rate
if snr > self.target_snr and self.data_rate < 5:
self.data_rate += 1
print(f"ADR: Increasing DR to {self.data_rate} (SNR: {snr:.1f})")
elif snr < self.min_snr and self.data_rate > 0:
self.data_rate -= 1
self.tx_power = min(self.tx_power + 2, 20)
print(f"ADR: Decreasing DR to {self.data_rate}, power to {self.tx_power}dBm")
def get_adr_link_req(self) -> bytes:
# Simplified LinkADRReq payload
return bytes([self.data_rate | (self.tx_power << 4) | 0x01])
adr = ADRController()
for rssi, snr in [(-90, 12), (-95, 9), (-105, -8), (-110, -12)]:
print(f"RSSI: {rssi} dBm, SNR: {snr} dB")
adr.process_uplink(rssi, snr)
Expected output:
RSSI: -90 dBm, SNR: 12 dB
ADR: Increasing DR to 5 (SNR: 12.0)
RSSI: -95 dBm, SNR: 9 dB
RSSI: -105 dBm, SNR: -8 dB
ADR: Decreasing DR to 4, power to 16dBm
RSSI: -110 dBm, SNR: -12 dB
ADR: Decreasing DR to 3, power to 18dBm
Gateway Packet Forwarding
Gateways use the Semtech UDP Packet Forwarder protocol. They don't Process packets — they forward raw radio RX data to the network server as JSON:
{
"rxpk": [{
"time": "2026-06-24T10:00:00.000Z",
"tmst": 1234567,
"chan": 0,
"rfch": 0,
"freq": 868.5,
"stat": 1,
"modu": "LORA",
"datr": "SF12BW125",
"codr": "4/5",
"rssi": -105,
"lsnr": -8.5,
"size": 23,
"data": "QAEBAgAABQABxNU+I/M=]
}]
}
Arduino Class A Sensor Example
#include <MKRWAN.h>
LoRaModem modem;
const char* appEui = "0000000000000000";
const char* appKey = "0123456789ABCDEF0123456789ABCDEF";
void setup() {
Serial.begin(115200);
while (!Serial);
if (!modem.begin(EU868)) {
Serial.println("Failed to start radio");
while (1) {}
}
Serial.print("Joining LoRaWAN...");
int connected = modem.joinOTAA(appEui, appKey);
if (!connected) {
Serial.println("Join failed");
while (1) {}
}
Serial.println("Joined!");
// Set Class A (default) and enable ADR
modem.setADR(true);
modem.dataRate(5); // Start at DR5 (SF7)
}
void loop() {
uint8_t payload[2];
payload[0] = 0xEB; // 23.5°C encoded as fixed-point
payload[1] = 0x03;
modem.beginPacket();
modem.write(payload, 2);
int err = modem.endPacket(false); // Unconfirmed uplink
if (err > 0) {
Serial.println("Uplink sent");
// RX1 window opens at +1s, RX2 at +2s
} else {
Serial.println("Uplink failed");
}
delay(600000); // 10 minutes between transmissions
}
Common Mistakes
Using ABP over OTAA: ABP bypasses key negotiation. Keys never rotate. If compromised, an attacker can impersonate the device forever. Use OTAA for production.
Not handling duty cycle restrictions: EU868 enforces 1% duty cycle. Transmitting 100 bytes at SF12 uses ~3 seconds of airtime. The device must then wait ~300 seconds before transmitting again on that frequency.
Sending downlink to Class A at the wrong time: Downlink must arrive in RX1 or RX2 Windows. The network server queues downlink until the next uplink triggers these Windows.
Ignoring ADRACKReq: ADRACKReq tells the network "I need a downlink to confirm ADR is working." If the device sends ADRACKReq and doesn't receive a downlink after several retries, it must revert to a conservative data rate.
No frequency plan configuration: EU868, US915, AU915, AS923 all have different channel plans. Using the wrong plan violates regulations and prevents the device from connecting.
Practice Questions
What information is exchanged during OTAA join? Device sends Join Request (DevEUI, AppEUI, DevNonce). Network verifies MIC with AppKey and sends Join Accept (DevAddr, NwkSKey, AppSKey, RX params, CFList).
How does the network server handle packets received by multiple gateways? The network deduplicates by FCnt + DevAddr + MIC. It keeps the packet from the gateway with the best RSSI/SNR and drops duplicates.
What is the purpose of MAC commands in LoRaWAN? MAC commands manage radio parameters: data rate, TX power, channels, duty cycle, device status. They travel in the FOpts field or on port 0.
Why does ADR improve battery life? ADR optimizes the data rate to use the fastest SF possible for reliable communication. SF7 uses 12x less airtime than SF12, reducing radio-on time and saving power.
When would you need a Class C device? Class C for actuators that must respond instantly (valve control, alarm, switch). The device listens continuously, receiving downlink immediately when the network sends it.
Mini Project
Simulate a LoRaWAN network server in Python:
import random, time, hashlib, json
class LoRaWANNetworkServer:
def __init__(self):
self.devices = {}
self.gateways = {}
def register_device(self, dev_eui: str, app_key: str):
self.devices[dev_eui] = {
"app_key": app_key,
"fcnt_up": 0,
"fcnt_down": 0,
"adr_enabled": True,
"data_rate": 5
}
def register_gateway(self, gw_id: str, lat: float, lng: float):
self.gateways[gw_id] = {"lat": lat, "lng": lng}
def process_uplink(self, dev_eui: str, payload: bytes,
gw_id: str, rssi: int, snr: float) -> dict:
if dev_eui not in self.devices:
return {"error": "Unknown device"}
device = self.devices[dev_eui]
device["fcnt_up"] += 1
# Simulate ADR adjustment
if snr > 10.0 and device["data_rate"] < 5:
device["data_rate"] += 1
adr_action = f"ADR: DR increased to {device['data_rate']}"
elif snr < -5.0 and device["data_rate"] > 0:
device["data_rate"] -= 1
adr_action = f"ADR: DR decreased to {device['data_rate']}"
else:
adr_action = "ADR: No change"
result = {
"dev_eui": dev_eui,
"gateway": gw_id,
"fcnt": device["fcnt_up"],
"rssi": rssi,
"snr": snr,
"data_rate": f"SF{12 - device['data_rate']}",
"adr": adr_action,
"payload_hex": payload.hex()
}
return result
ns = LoRaWANNetworkServer()
ns.register_device("00:11:22:33:44:55:66:77", "0123456789ABCDEF0123456789ABCDEF")
ns.register_gateway("gw-london-01", 51.5, -0.1)
for i in range(5):
result = ns.process_uplink(
"00:11:22:33:44:55:66:77",
bytes([0xEB, 0x03]),
"gw-london-01",
rssi=random.randint(-115, -80),
snr=random.uniform(-10, 15)
)
print(json.dumps(result, indent=2))
time.sleep(0.2)
Cross-References
- LoRaWAN & LoRa
- IoT Communication Protocols
- IoT Gateways
- IoT Security
- IoT Overview
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro