SX1276 AT Command LoRaWAN Modem (RAK RUI3-Style)
This guide provides a complete, production-ready AT Command Modem firmware for an Espressif ESP32-WROOM-32 paired with a Semtech SX1276 (or HopeRF RFM95W) transceiver. The firmware adheres to industry-standard RAK RUI3-style AT command syntax, stores all configuration persistently in ESP32 Non-Volatile Storage (NVS), and communicates directly with The Things Network (TTN) and Cubicore Gateways.
1. Overview — Flash Once, Configure Anything via Serial
In traditional embedded LoRaWAN prototyping (such as the sketch presented in Chapter 2: SX1276 LoRaWAN with RadioLib), credentials like DevEUI, AppKey, and network settings like Spreading Factor (DR) are hardcoded directly into C++ arrays. Every time you register a new test device, change your frequency band, or switch between dual-channel and 8-channel gateways, you must modify your source code, open the Arduino IDE, recompile the entire binary, and flash the microcontroller over USB.
An AT Command LoRaWAN Modem eliminates this friction completely.
| Workflow Step | Hardcoded C++ Firmware | RUI3-Style AT Command Modem |
|---|---|---|
| Step 1: Configuration | Edit C++ byte arrays in source code | Flash firmware ONCE via USB |
| Step 2: Deployment | Compile sketch (45–60 s) & flash over USB | Open Serial Monitor at 115200 baud |
| Step 3: Credential Update | Recompile & re-flash entire binary | Send: AT+DEVEUI=70B3D5... & AT+APPKEY=A1B2... |
| Step 4: Network Join | Hardcoded in setup() | Send: AT+JOIN (dynamic interactive join) |
| Step 5: Telemetry | Hardcoded periodic timer | Send: AT+SEND=10:01020304 |
| Device Swap Time | 2–3 minutes per unit | ~5 seconds via serial |
By turning your ESP32 + SX1276 into a serial AT command modem:
- Interactive Bench Prototyping: Configure and test network parameters in seconds directly from the Arduino IDE Serial Monitor, PuTTY, minicom, or screen.
- Microcontroller Co-Processor Mode: Use the ESP32 + SX1276 as a dedicated LoRaWAN wireless modem for an external host controller (e.g., STM32, Raspberry Pi Pico, Arduino Uno, or Python script on Linux) over simple UART TX/RX lines.
- Zero Recompilation Device Provisioning: Production staff can flash a single master
.binfile across hundreds of boards, then commission uniqueDevEUIandAppKeycredentials via automated serial scripts.
NVS Persistence & Zero Flash Wear Architecture
Every parameter you set via serial commands—including DevEUI, AppEUI, AppKey, Band, ADR, DR, TXP, CFM, and the monotonically increasing DevNonce anti-replay ledger—is automatically stored in ESP32 Non-Volatile Storage (NVS) using the built-in <Preferences.h> library under the "at_modem" namespace.
- Persistent Across Reboots: When power is cycled or the software reboot command (
ATZ) is executed, the modem restores all saved parameters and nonces immediately. - Zero Flash Wear During Uplinks: In full accordance with Cubicore's "Flash for Bootstrapping, RAM for Runtime" standard, NVS writes occur only when you execute an explicit configuration command or complete an OTAA join. During continuous uplink transmission (
AT+SEND), frame counters and radio FIFOs run 100% in RAM—preventing premature NOR flash silicon gate fatigue.
2. Hardware Wiring (ESP32-WROOM-32 + SX1276 / RFM95W)
The AT modem firmware utilizes the ESP32 hardware VSPI peripheral bus. Wire your standalone SX1276, HopeRF RFM95W, or AI-Thinker Ra-01H module to an ESP32 DevKitC (30-pin or 38-pin ESP-WROOM-32) according to the table below:
| SX1276 / RFM95 Pin | ESP32 DevKit Pin | Function | Notes |
|---|---|---|---|
VCC / 3.3V | 3V3 | 3.3V Power | Must connect to 3.3V rail. Do NOT connect to 5V or VIN! |
GND | GND | Ground | Common ground reference. |
SCK | GPIO 18 | VSPI Clock | SPI Serial Clock. |
MISO | GPIO 19 | VSPI MISO | Master In / Slave Out (Data from SX1276 to ESP32). |
MOSI | GPIO 23 | VSPI MOSI | Master Out / Slave In (Data from ESP32 to SX1276). |
NSS / CS | GPIO 5 | SPI Chip Select | Active-low chip select. |
RESET / RST | GPIO 14 | Hardware Reset | Active-low transceiver reset. |
DIO0 | GPIO 26 | Interrupt 0 | Mandatory: Signals TxDone and RxDone. |
DIO1 | GPIO 27 | Interrupt 1 | Mandatory: Signals RxTimeout for LoRaWAN RX1/RX2 windows. |
ANT | SMA Bulkhead | 50Ω RF Output | Connect tuned 868/915 MHz antenna before powering on! |
- Both
DIO0andDIO1are Mandatory: Unlike raw LoRa P2P examples which only useDIO0, LoRaWAN Class A operation strictly requiresDIO1to detect when receive windows timeout. LeavingDIO1disconnected will cause everyAT+JOINattempt to fail with+EVT:JOIN_FAILED_RX_TIMEOUT. - Never Transmit Without an Antenna: Transmitting at high power (+20 dBm) without a 50-ohm load creates high Voltage Standing Wave Ratio (VSWR), reflecting RF power back into the SX1276
PA_BOOSToutput stage and destroying the transceiver.
3. Complete ESP32 + SX1276 AT Command Modem Sketch
Copy and paste the complete, single-file .ino sketch below into your Arduino IDE. It compiles cleanly with the RadioLib library (v7.4.0 or newer) and the built-in ESP32 core <Preferences.h> library without requiring any external dependencies.
/*
* =====================================================================================
* Cubicore LoRaWAN Guides - SX1276 RAK RUI3-Style AT Command LoRaWAN Modem
* Target: ESP32 DevKitC (ESP-WROOM-32) + Semtech SX1276 / HopeRF RFM95W
* Library: RadioLib by Jan Gromes (v7.4.0+)
* Storage: ESP32 Non-Volatile Storage (Preferences.h)
*
* Features:
* - RAK RUI3-compatible AT command parser over Serial (115200 baud).
* - Plain 16-hex DevEUI/AppEUI and 32-hex AppKey parser (direct copy from TTN).
* - Persistent NVS storage for credentials, regional band, ADR, DR, TXP, and CFM.
* - LoRaWAN 1.0.4 OTAA activation with automatic DevNonce anti-replay ledger.
* - Variable-length binary hex uplink transmission (AT+SEND) and downlink reporting.
* - Cubicore dual-channel gateway mode (AT+ADR=0, AT+DR=2 on AS923-3).
* =====================================================================================
*/
#include <RadioLib.h>
#include <SPI.h>
#include <Preferences.h>
#include <stdlib.h>
// SX1276 Pin Definitions (ESP32 VSPI)
#define LORA_NSS 5
#define LORA_DIO0 26
#define LORA_RESET 14
#define LORA_DIO1 27
#define LORA_SCK 18
#define LORA_MISO 19
#define LORA_MOSI 23
// Onboard diagnostic LED (GPIO 2 on standard ESP32 DevKits)
#define PIN_LED 2
// Instantiate RadioLib Module & Radio
Module* loraMod = new Module(LORA_NSS, LORA_DIO0, LORA_RESET, LORA_DIO1);
SX1276 radio(loraMod);
// LoRaWAN Node Pointer (initialized dynamically based on configured band)
LoRaWANNode* node = nullptr;
// NVS Storage Engine
Preferences prefs;
#define NONCES_BUF_SIZE RADIOLIB_LORAWAN_NONCES_BUF_SIZE
// Modem Configuration State
struct ModemConfig {
uint64_t devEUI;
uint64_t joinEUI;
uint8_t appKey[16];
uint8_t nwkKey[16];
uint8_t band; // 10 = AS923-3 (default), 8 = AS923-1, 4 = EU868, 5 = US915
uint8_t adr; // 0 = Disabled (Dual-channel mode), 1 = Enabled (8-channel)
uint8_t dr; // 0..5 (Default 2 = SF10 BW125 for AS923-3)
int8_t txp; // 2..20 dBm (Default 20)
uint8_t cfm; // 0 = Unconfirmed, 1 = Confirmed
} cfg;
// Serial Command Buffer
String inputBuffer = "";
// Forward Declarations
void loadConfigFromNVS();
void saveConfigToNVS();
void initLoRaWANStack();
void processCommand(String cmd);
String uint64ToHex(uint64_t val);
String bytesToHex(const uint8_t* b, size_t len);
bool parseHexUint64(const String& str, uint64_t& out);
bool parseHex16Bytes(const String& str, uint8_t* out);
bool parseHexPayload(const String& str, uint8_t* out, size_t& len);
// =====================================================================================
// SETUP & MAIN LOOP
// =====================================================================================
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 3000);
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, LOW);
// Initialize hardware VSPI
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_NSS);
// Initialize SX1276 Transceiver
int16_t state = radio.begin();
if (state != RADIOLIB_ERR_NONE) {
Serial.printf("\r\n+BOOT:RADIO_ERROR:%d (Check SPI wiring & 3.3V power)\r\n", state);
}
// Load persistent settings from NVS
loadConfigFromNVS();
// Initialize LoRaWAN Stack with loaded configuration
initLoRaWANStack();
Serial.println(F("\r\n======================================================="));
Serial.println(F(" Cubicore SX1276 AT Command LoRaWAN Modem (RUI3) "));
Serial.println(F(" Type 'AT?' or 'AT+HELP' for supported commands "));
Serial.println(F("======================================================="));
Serial.print(F("AT+STATUS=?\r\n"));
processCommand("AT+STATUS=?");
}
void loop() {
// Read incoming serial characters from host or terminal
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\r' || c == '\n') {
inputBuffer.trim();
if (inputBuffer.length() > 0) {
processCommand(inputBuffer);
inputBuffer = "";
}
} else if (c == '\b' || c == 0x7F) {
if (inputBuffer.length() > 0) {
inputBuffer.remove(inputBuffer.length() - 1);
}
} else {
if (inputBuffer.length() < 512) {
inputBuffer += c;
}
}
}
}
// =====================================================================================
// NVS STORAGE & RADIO INITIALIZATION
// =====================================================================================
void loadConfigFromNVS() {
prefs.begin("at_modem", false);
cfg.devEUI = prefs.getULong64("deveui", 0x70B3D57ED007940BULL);
cfg.joinEUI = prefs.getULong64("joineui", 0x0000000000000000ULL);
// Default AppKey: B07B60C17ED058FE8D3B76541063D0B1
uint8_t defaultKey[16] = {
0xB0, 0x7B, 0x60, 0xC1, 0x7E, 0xD0, 0x58, 0xFE,
0x8D, 0x3B, 0x76, 0x54, 0x10, 0x63, 0xD0, 0xB1
};
if (prefs.isKey("appkey")) {
prefs.getBytes("appkey", cfg.appKey, 16);
} else {
memcpy(cfg.appKey, defaultKey, 16);
}
// In LoRaWAN 1.0.4, nwkKey == appKey
memcpy(cfg.nwkKey, cfg.appKey, 16);
cfg.band = prefs.getUChar("band", 10); // Default 10 = AS923-3
cfg.adr = prefs.getUChar("adr", 0); // Default 0 = Disabled (Dual-channel mode)
cfg.dr = prefs.getUChar("dr", 2); // Default 2 = SF10 BW125
cfg.txp = prefs.getChar("txp", 20); // Default 20 dBm
cfg.cfm = prefs.getUChar("cfm", 0); // Default 0 = Unconfirmed
prefs.end();
}
void saveConfigToNVS() {
prefs.begin("at_modem", false);
prefs.putULong64("deveui", cfg.devEUI);
prefs.putULong64("joineui", cfg.joinEUI);
prefs.putBytes("appkey", cfg.appKey, 16);
prefs.putUChar("band", cfg.band);
prefs.putUChar("adr", cfg.adr);
prefs.putUChar("dr", cfg.dr);
prefs.putChar("txp", cfg.txp);
prefs.putUChar("cfm", cfg.cfm);
prefs.end();
}
void initLoRaWANStack() {
if (node != nullptr) {
delete node;
node = nullptr;
}
// Select band pointer based on configured ID
const LoRaWANBand_t* selectedBand = &AS923_3;
if (cfg.band == 8) selectedBand = &AS923_1;
else if (cfg.band == 4) selectedBand = &EU868;
else if (cfg.band == 5) selectedBand = &US915;
node = new LoRaWANNode(&radio, selectedBand);
// Initialize OTAA stack
node->beginOTAA(cfg.joinEUI, cfg.devEUI, cfg.nwkKey, cfg.appKey);
// Apply MAC and RF settings
node->setADR(cfg.adr == 1);
node->setDatarate(cfg.dr);
node->setTxPower(cfg.txp);
}
// =====================================================================================
// AT COMMAND PARSER
// =====================================================================================
void processCommand(String cmd) {
cmd.trim();
String upperCmd = cmd;
upperCmd.toUpperCase();
// 1. Basic AT Test
if (upperCmd == "AT") {
Serial.println(F("OK"));
return;
}
// 2. Help
if (upperCmd == "AT?" || upperCmd == "AT+HELP") {
Serial.println(F("\r\n--- Supported RAK RUI3-Style AT Commands ---"));
Serial.println(F(" AT : Test modem responsiveness"));
Serial.println(F(" AT+STATUS=? : Show current parameters & join state"));
Serial.println(F(" ATZ : Reboot modem"));
Serial.println(F(" ATR : Factory reset NVS and reboot"));
Serial.println(F(" AT+DEVEUI=? / =<16-hex> : Query / Set DevEUI"));
Serial.println(F(" AT+APPEUI=? / =<16-hex> : Query / Set AppEUI (JoinEUI)"));
Serial.println(F(" AT+APPKEY=? / =<32-hex> : Query / Set AppKey (128-bit)"));
Serial.println(F(" AT+BAND=? / =<id> : Query / Set Band (10=AS923-3, 8=AS923-1, 4=EU868, 5=US915)"));
Serial.println(F(" AT+ADR=? / =<0|1> : Query / Set ADR (0=Off for Dual-Channel, 1=On)"));
Serial.println(F(" AT+DR=? / =<0..5> : Query / Set Data Rate (2=SF10 BW125)"));
Serial.println(F(" AT+TXP=? / =<2..20> : Query / Set TX Power in dBm"));
Serial.println(F(" AT+CFM=? / =<0|1> : Query / Set Uplink Mode (0=Unconfirmed, 1=Confirmed)"));
Serial.println(F(" AT+JOIN or AT+JOIN=1 : Execute OTAA network join"));
Serial.println(F(" AT+NJS=? : Network Join Status (1=Joined, 0=Not Joined)"));
Serial.println(F(" AT+SEND=<port>:<hex> : Send hex payload on specified port (1..223)"));
Serial.println(F(" AT+DEVNONCE=? / =RESET : Query DevNonce record / Reset saved nonce"));
Serial.println(F("OK"));
return;
}
// 3. Status Summary
if (upperCmd == "AT+STATUS=?") {
Serial.println(F("+STATUS:"));
Serial.printf(" DevEUI: %s\r\n", uint64ToHex(cfg.devEUI).c_str());
Serial.printf(" AppEUI: %s\r\n", uint64ToHex(cfg.joinEUI).c_str());
Serial.printf(" AppKey: %s\r\n", bytesToHex(cfg.appKey, 16).c_str());
String bandStr = "Unknown";
if (cfg.band == 10) bandStr = "AS923-3 (916.6 / 916.8 MHz - Philippines)";
else if (cfg.band == 8) bandStr = "AS923-1 (923.2 / 923.4 MHz)";
else if (cfg.band == 4) bandStr = "EU868 (868.1 / 868.3 / 868.5 MHz)";
else if (cfg.band == 5) bandStr = "US915";
Serial.printf(" Band: %d (%s)\r\n", cfg.band, bandStr.c_str());
Serial.printf(" ADR: %d (%s)\r\n", cfg.adr, cfg.adr ? "Enabled (8-Channel)" : "Disabled (Dual-Channel Pocket/Hub)");
Serial.printf(" DR: %d (SF%d / 125 kHz)\r\n", cfg.dr, (12 - cfg.dr));
Serial.printf(" TX Power: %d dBm\r\n", cfg.txp);
Serial.printf(" Confirm: %d (%s)\r\n", cfg.cfm, cfg.cfm ? "Confirmed" : "Unconfirmed");
Serial.printf(" Joined: %d (%s)\r\n", (node && node->isActivated()) ? 1 : 0,
(node && node->isActivated()) ? "JOINED" : "NOT JOINED");
Serial.println(F("OK"));
return;
}
// 4. Reboot (ATZ)
if (upperCmd == "ATZ") {
Serial.println(F("OK"));
Serial.println(F("+EVT:RESTARTING"));
delay(150);
ESP.restart();
return;
}
// 5. Factory Reset (ATR)
if (upperCmd == "ATR") {
prefs.begin("at_modem", false);
prefs.clear();
prefs.end();
Serial.println(F("OK"));
Serial.println(F("+EVT:FACTORY_RESET_REBOOTING"));
delay(150);
ESP.restart();
return;
}
// 6. DevEUI
if (upperCmd == "AT+DEVEUI=?") {
Serial.printf("AT+DEVEUI=%s\r\n", uint64ToHex(cfg.devEUI).c_str());
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+DEVEUI=")) {
String val = cmd.substring(10);
val.trim();
uint64_t parsed;
if (parseHexUint64(val, parsed)) {
cfg.devEUI = parsed;
saveConfigToNVS();
initLoRaWANStack();
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 7. AppEUI / JoinEUI
if (upperCmd == "AT+APPEUI=?" || upperCmd == "AT+JOINEUI=?") {
Serial.printf("AT+APPEUI=%s\r\n", uint64ToHex(cfg.joinEUI).c_str());
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+APPEUI=") || upperCmd.startsWith("AT+JOINEUI=")) {
int idx = cmd.indexOf('=');
String val = cmd.substring(idx + 1);
val.trim();
uint64_t parsed;
if (parseHexUint64(val, parsed)) {
cfg.joinEUI = parsed;
saveConfigToNVS();
initLoRaWANStack();
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 8. AppKey
if (upperCmd == "AT+APPKEY=?") {
Serial.printf("AT+APPKEY=%s\r\n", bytesToHex(cfg.appKey, 16).c_str());
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+APPKEY=")) {
String val = cmd.substring(10);
val.trim();
uint8_t parsed[16];
if (parseHex16Bytes(val, parsed)) {
memcpy(cfg.appKey, parsed, 16);
memcpy(cfg.nwkKey, parsed, 16); // In LoRaWAN 1.0.4, nwkKey == appKey
saveConfigToNVS();
initLoRaWANStack();
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 9. Regional Band
if (upperCmd == "AT+BAND=?") {
Serial.printf("AT+BAND=%d\r\n", cfg.band);
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+BAND=")) {
int b = cmd.substring(8).toInt();
if (b == 10 || b == 8 || b == 4 || b == 5) {
cfg.band = (uint8_t)b;
saveConfigToNVS();
initLoRaWANStack();
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 10. Adaptive Data Rate (ADR)
if (upperCmd == "AT+ADR=?") {
Serial.printf("AT+ADR=%d\r\n", cfg.adr);
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+ADR=")) {
int a = cmd.substring(7).toInt();
if (a == 0 || a == 1) {
cfg.adr = (uint8_t)a;
saveConfigToNVS();
if (node) node->setADR(cfg.adr == 1);
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 11. Data Rate (DR)
if (upperCmd == "AT+DR=?") {
Serial.printf("AT+DR=%d\r\n", cfg.dr);
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+DR=")) {
int d = cmd.substring(6).toInt();
if (d >= 0 && d <= 5) {
cfg.dr = (uint8_t)d;
saveConfigToNVS();
if (node) node->setDatarate(cfg.dr);
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 12. Transmit Power (TXP)
if (upperCmd == "AT+TXP=?") {
Serial.printf("AT+TXP=%d\r\n", cfg.txp);
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+TXP=")) {
int p = cmd.substring(7).toInt();
if (p >= 2 && p <= 20) {
cfg.txp = (int8_t)p;
saveConfigToNVS();
if (node) node->setTxPower(cfg.txp);
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 13. Confirm Mode (CFM)
if (upperCmd == "AT+CFM=?") {
Serial.printf("AT+CFM=%d\r\n", cfg.cfm);
Serial.println(F("OK"));
return;
}
if (upperCmd.startsWith("AT+CFM=")) {
int cVal = cmd.substring(7).toInt();
if (cVal == 0 || cVal == 1) {
cfg.cfm = (uint8_t)cVal;
saveConfigToNVS();
Serial.println(F("OK"));
} else {
Serial.println(F("AT_PARAM_ERROR"));
}
return;
}
// 14. Network Join Status (NJS)
if (upperCmd == "AT+NJS=?") {
Serial.printf("AT+NJS=%d\r\n", (node && node->isActivated()) ? 1 : 0);
Serial.println(F("OK"));
return;
}
// 15. DevNonce Persistence Query / Reset
if (upperCmd == "AT+DEVNONCE=?") {
prefs.begin("at_modem", false);
bool hasRecord = prefs.isKey("nonces");
prefs.end();
Serial.printf("AT+DEVNONCE=%s\r\n", hasRecord ? "SAVED_IN_NVS" : "EMPTY");
Serial.println(F("OK"));
return;
}
if (upperCmd == "AT+DEVNONCE=RESET") {
prefs.begin("at_modem", false);
prefs.remove("nonces");
prefs.end();
Serial.println(F("OK"));
Serial.println(F("+EVT:DEVNONCE_RESET_COMPLETE"));
return;
}
// 16. Join Command (AT+JOIN or AT+JOIN=1)
if (upperCmd == "AT+JOIN" || upperCmd == "AT+JOIN=1") {
if (node == nullptr) {
Serial.println(F("AT_ERROR"));
return;
}
Serial.println(F("OK"));
Serial.println(F("+EVT:JOINING"));
// 1. Restore previous DevNonce buffer from NVS if present
prefs.begin("at_modem", false);
if (prefs.isKey("nonces")) {
uint8_t noncesBuf[NONCES_BUF_SIZE];
prefs.getBytes("nonces", noncesBuf, NONCES_BUF_SIZE);
node->setBufferNonces(noncesBuf);
}
prefs.end();
// 2. Ensure current parameters are applied
node->setADR(cfg.adr == 1);
node->setDatarate(cfg.dr);
node->setTxPower(cfg.txp);
// 3. Initiate OTAA Join Request
digitalWrite(PIN_LED, HIGH);
int16_t joinState = node->activateOTAA();
digitalWrite(PIN_LED, LOW);
if (joinState == RADIOLIB_LORAWAN_NEW_SESSION) {
// Save newly incremented DevNonce to NVS EXACTLY ONCE on join accept
prefs.begin("at_modem", false);
prefs.putBytes("nonces", node->getBufferNonces(), NONCES_BUF_SIZE);
prefs.end();
Serial.println(F("+EVT:JOINED"));
} else {
Serial.printf("+EVT:JOIN_FAILED (Error %d)\r\n", joinState);
}
return;
}
// 17. Send Telemetry (AT+SEND=<port>:<hex_payload>)
if (upperCmd.startsWith("AT+SEND=")) {
if (node == nullptr || !node->isActivated()) {
Serial.println(F("AT_NO_NETWORK_JOINED"));
return;
}
String params = cmd.substring(8);
int colonIdx = params.indexOf(':');
if (colonIdx == -1) {
Serial.println(F("AT_PARAM_ERROR"));
return;
}
int port = params.substring(0, colonIdx).toInt();
if (port < 1 || port > 223) {
Serial.println(F("AT_PARAM_ERROR"));
return;
}
String hexPayload = params.substring(colonIdx + 1);
hexPayload.trim();
uint8_t payload[256];
size_t payloadLen = 0;
if (!parseHexPayload(hexPayload, payload, payloadLen) || payloadLen == 0 || payloadLen > 242) {
Serial.println(F("AT_PARAM_ERROR"));
return;
}
Serial.println(F("OK"));
// Transmit via RadioLib
digitalWrite(PIN_LED, HIGH);
int16_t state = node->sendReceive(payload, payloadLen, (uint8_t)port, (cfg.cfm == 1));
digitalWrite(PIN_LED, LOW);
if (state == RADIOLIB_ERR_NONE) {
Serial.println(F("+EVT:TX_DONE"));
} else if (state > 0) {
// Downlink bytes received!
Serial.println(F("+EVT:TX_DONE"));
uint8_t rxBuf[256];
size_t rxLen = 0;
node->getDownlinkData(rxBuf, &rxLen);
float rssi = radio.getRSSI();
float snr = radio.getSNR();
String hexRx = bytesToHex(rxBuf, rxLen);
Serial.printf("+EVT:RX:%d:%.1f:%.1f:%s\r\n", port, rssi, snr, hexRx.c_str());
} else {
Serial.printf("+EVT:TX_FAILED (Error %d)\r\n", state);
}
return;
}
// Unknown Command
Serial.println(F("AT_ERROR"));
}
// =====================================================================================
// STRING & HEX CONVERSION HELPERS
// =====================================================================================
String uint64ToHex(uint64_t val) {
char buf[17];
snprintf(buf, sizeof(buf), "%08X%08X", (uint32_t)(val >> 32), (uint32_t)(val & 0xFFFFFFFF));
return String(buf);
}
String bytesToHex(const uint8_t* b, size_t len) {
String s = "";
s.reserve(len * 2);
for (size_t i = 0; i < len; i++) {
char buf[3];
snprintf(buf, sizeof(buf), "%02X", b[i]);
s += buf;
}
return s;
}
bool parseHexUint64(const String& str, uint64_t& out) {
if (str.length() != 16) return false;
uint64_t result = 0;
for (int i = 0; i < 16; i++) {
char c = str.charAt(i);
uint8_t nibble;
if (c >= '0' && c <= '9') nibble = c - '0';
else if (c >= 'A' && c <= 'F') nibble = c - 'A' + 10;
else if (c >= 'a' && c <= 'f') nibble = c - 'a' + 10;
else return false;
result = (result << 4) | nibble;
}
out = result;
return true;
}
bool parseHex16Bytes(const String& str, uint8_t* out) {
if (str.length() != 32) return false;
for (int i = 0; i < 16; i++) {
char byteChars[3] = { str.charAt(i * 2), str.charAt(i * 2 + 1), '\0' };
char* endPtr;
long byteVal = strtol(byteChars, &endPtr, 16);
if (*endPtr != '\0' || byteVal < 0 || byteVal > 255) return false;
out[i] = (uint8_t)byteVal;
}
return true;
}
bool parseHexPayload(const String& str, uint8_t* out, size_t& len) {
if (str.length() == 0 || (str.length() % 2) != 0) return false;
len = str.length() / 2;
for (size_t i = 0; i < len; i++) {
char byteChars[3] = { str.charAt(i * 2), str.charAt(i * 2 + 1), '\0' };
char* endPtr;
long byteVal = strtol(byteChars, &endPtr, 16);
if (*endPtr != '\0' || byteVal < 0 || byteVal > 255) return false;
out[i] = (uint8_t)byteVal;
}
return true;
}
4. Complete AT Command Reference Manual
Serial Terminal Settings
Configure your host serial terminal (Arduino IDE Serial Monitor, PuTTY, Tera Term, minicom, or Python pyserial) with the following line parameters:
- Baud Rate:
115200 bps - Data Bits:
8 - Parity:
None - Stop Bits:
1 - Line Ending: Set to
Newline(\n),Carriage Return(\r), orBoth NL & CR(\r\n).
Command Syntax Conventions
- Query Syntax (
=?): Returns the currently active parameter stored in memory (e.g.,AT+DR=?). - Set Syntax (
=<param>): Updates the parameter in runtime memory, saves it immediately to NVS flash, and reconfigures the radio stack (e.g.,AT+DR=2). - Case Insensitive: Commands may be entered in uppercase (
AT+JOIN) or lowercase (at+join).
Master AT Command Reference Table
| Category | Command | Parameters | Description & Behavior |
|---|---|---|---|
| System | AT | None | Basic AT ping. Returns OK if modem firmware is responsive. |
| System | AT? or AT+HELP | None | Displays the formatted quick reference command directory. |
| System | AT+STATUS=? | None | Displays a comprehensive overview of all credentials, RF settings, and network join status. |
| System | ATZ | None | Triggers an immediate software reboot via ESP.restart(). |
| System | ATR | None | Factory reset: Erases all "at_modem" NVS namespaces and restarts the ESP32. |
| Identity | AT+DEVEUI=? | None | Queries the current 64-bit Device EUI formatted as a 16-character hex string. |
| Identity | AT+DEVEUI=<hex> | 16 hex chars | Configures a new 64-bit Device EUI (e.g., AT+DEVEUI=70B3D57ED007940B). Saves to NVS. |
| Identity | AT+APPEUI=? | None | Queries the current 64-bit JoinEUI / AppEUI. |
| Identity | AT+APPEUI=<hex> | 16 hex chars | Configures the 64-bit JoinEUI (e.g., AT+APPEUI=0000000000000000). Saves to NVS. |
| Identity | AT+APPKEY=? | None | Queries the current 128-bit Application Root Key (32 hex characters). |
| Identity | AT+APPKEY=<hex> | 32 hex chars | Configures the 128-bit AES AppKey. Sets both appKey and nwkKey for LoRaWAN 1.0.4. Saves to NVS. |
| RF / MAC | AT+BAND=? | None | Queries the active regional band ID (10 = AS923-3). |
| RF / MAC | AT+BAND=<id> | 10, 8, 4, 5 | Selects regional parameters: 10 = AS923-3, 8 = AS923-1, 4 = EU868, 5 = US915. Saves to NVS. |
| RF / MAC | AT+ADR=? | None | Queries Adaptive Data Rate state (0 = Off, 1 = On). |
| RF / MAC | AT+ADR=<0|1> | 0 or 1 | 0: Disabled (Required for Cubicore dual-channel gateways).1: Enabled (Standard 8-channel multi-channel networks). |
| RF / MAC | AT+DR=? | None | Queries the current Data Rate index (0 to 5). |
| RF / MAC | AT+DR=<0..5> | 0 to 5 | Sets the transmission Datarate. 2 = SF10 BW125 (Required for Cubicore dual-channel gateways). |
| RF / MAC | AT+TXP=? | None | Queries the configured RF transmit power in dBm. |
| RF / MAC | AT+TXP=<p> | 2 to 20 | Sets output power in dBm (Default 20 via PA_BOOST). Saves to NVS. |
| RF / MAC | AT+CFM=? | None | Queries uplink confirmation mode (0 = Unconfirmed, 1 = Confirmed). |
| RF / MAC | AT+CFM=<0|1> | 0 or 1 | Sets confirmation mode: 0 for unconfirmed uplinks, 1 for acknowledged uplinks. |
| Network | AT+JOIN | None or =1 | Initiates an OTAA activation exchange with TTN. Returns OK followed by asynchronous event. |
| Network | AT+NJS=? | None | Network Join Status: Returns AT+NJS=1 if currently joined, or AT+NJS=0 if disconnected. |
| Network | AT+SEND=<p>:<hex> | <port>:<hex> | Sends variable-length hex payload on LoRaWAN port 1..223. Returns OK followed by +EVT:TX_DONE. |
| Ledger | AT+DEVNONCE=? | None | Queries whether a saved DevNonce ledger exists in NVS. |
| Ledger | AT+DEVNONCE=RESET | None | Clears saved "nonces" in NVS when you click "Reset used DevNonces" in TTN Console. |
AS923-3 Datarate (AT+DR) Reference Table
Under the Philippine AS923 Group 3 frequency plan, data rates map to the following Spreading Factors and physical bitrates:
| DR Index | Spreading Factor | Bandwidth | Indicative Bitrate | Airtime (6-Byte Payload) | Gateway Compatibility |
|---|---|---|---|---|---|
DR0 | SF12 | 125 kHz | 250 bps | ~1155 ms | Standard 8-Channel Concentrator only |
DR1 | SF11 | 125 kHz | 440 bps | ~577 ms | Standard 8-Channel Concentrator only |
DR2 | SF10 | 125 kHz | 980 bps | ~164 ms | Cubicore Gateway Pocket & Gateway Hub (Mandatory Default) |
DR3 | SF9 | 125 kHz | 1760 bps | ~82 ms | Standard 8-Channel Concentrator only |
DR4 | SF8 | 125 kHz | 3125 bps | ~46 ms | Standard 8-Channel Concentrator only |
DR5 | SF7 | 125 kHz | 5470 bps | ~26 ms | Standard 8-Channel Concentrator only |
When forwarding through a Cubicore Gateway Pocket or Cubicore Gateway Hub, you must configure:
AT+ADR=0
AT+DR=2
This locks your node to SF10 / 125 kHz and disables automatic network server rate scaling, ensuring every uplink matches the fixed receive channels (916.6 MHz and 916.8 MHz) of the dual-channel hardware.
Modem Return Codes & Asynchronous Events
| Response Type | Return Codes / Events | Execution Timing & Behavior |
|---|---|---|
| Synchronous Responses | OKAT_ERRORAT_PARAM_ERRORAT_NO_NETWORK_JOINED | Returned immediately upon evaluating the incoming command string. |
| Asynchronous Events | +EVT:JOINING+EVT:JOINED+EVT:JOIN_FAILED (Error <code>)+EVT:TX_DONE+EVT:RX:<port>:<rssi>:<snr>:<hex> | Emitted dynamically when physical RF transactions (TX completion, downlink arrival, or timeout) finish. |
Synchronous Responses
OK: The command was parsed successfully, verified, and applied.AT_ERROR: General syntax error or unrecognized command.AT_PARAM_ERROR: Parameter out of range, invalid character length, or non-hexadecimal input.AT_NO_NETWORK_JOINED: Emitted when attempting to transmit (AT+SEND) before completing a successfulAT+JOIN.
Asynchronous Event Codes
+EVT:JOINING: The transceiver has initiated the OTAAJoinRequesttransmission.+EVT:JOINED:JoinAcceptwas received, session keys were derived, and nonces were persisted to NVS.+EVT:JOIN_FAILED (Error <code>): Join attempt failed. RadioLib error code provided (e.g.,-1107for RX timeout).+EVT:TX_DONE: Uplink packet was transmitted successfully on the configured frequency and datarate.+EVT:RX:<port>:<rssi>:<snr>:<hex_payload>: Downlink data received from the network server during RX1 or RX2.
5. Step-by-Step Interactive Walkthrough (Join TTN & Send Packets)
Follow this complete interactive walkthrough to provision your node on The Things Network, configure it via serial, and transmit packed telemetry.
Step 1: Register End Device in TTN Console
- Log into your The Things Network Console (Select cluster
au1for Asia/Philippines). - Open your Application → Click + Register end device → Select Enter end device specifics manually.
- Set the Network Layer specifications:
- Frequency plan:
Asia 920-923 MHz (AS923 Group 3) with only default channels and dwell time disabled - LoRaWAN version:
LoRaWAN Specification 1.0.4 - Regional Parameters:
RP002 Regional Parameters 1.0.4
- Frequency plan:
- Under Provisioning:
- JoinEUI:
0000000000000000 - DevEUI: Click Generate (e.g.,
70B3D57ED007940B) - AppKey: Click Generate (e.g.,
112233445566778899AABBCCDDEEFF00)
- JoinEUI:
- Click Register end device.
Unlike hardcoded sketches where you had to click <> to format keys into C arrays with curly braces and 0x prefixes, this AT modem accepts the plain 16-character and 32-character hexadecimal strings copied directly from the TTN console!
Step 2: Configure Identity & Gateway Mode via Serial Monitor
Open the Arduino IDE Serial Monitor at 115200 baud with line ending set to Both NL & CR. Issue the configuration sequence:
AT
OK
AT+DEVEUI=70B3D57ED007940B
OK
AT+APPEUI=0000000000000000
OK
AT+APPKEY=112233445566778899AABBCCDDEEFF00
OK
AT+ADR=0
OK
AT+DR=2
OK
AT+STATUS=?
+STATUS:
DevEUI: 70B3D57ED007940B
AppEUI: 0000000000000000
AppKey: 112233445566778899AABBCCDDEEFF00
Band: 10 (AS923-3 (916.6 / 916.8 MHz - Philippines))
ADR: 0 (Disabled (Dual-Channel Pocket/Hub))
DR: 2 (SF10 / 125 kHz)
TX Power: 20 dBm
Confirm: 0 (Unconfirmed)
Joined: 0 (NOT JOINED)
OK
Step 3: Join The Things Network (AT+JOIN)
With your antenna attached and your Cubicore Gateway Pocket or Gateway Hub online, execute the join command:
AT+JOIN
OK
+EVT:JOINING
+EVT:JOINED
AT+NJS=?
AT+NJS=1
OK
In the TTN Console Live data view, you will see the incoming Join-request followed immediately by a Join-accept downlink message!
Step 4: Encode & Send Binary Hex Packets (AT+SEND)
Now send a packed 6-byte telemetry payload on LoRaWAN Port 10.
Understanding the 6-Byte Binary Encoding:
- Packet Counter #1:
0x0001(2 bytes) - Temperature (25.42 °C * 100 = 2542):
0x09EE(2 bytes) - Humidity (65.20 % * 100 = 6520):
0x1978(2 bytes) - Concatenated Hex Payload:
000109EE1978(6 bytes / 12 hex characters)
Issue the transmit command:
AT+SEND=10:000109EE1978
OK
+EVT:TX_DONE
TTN JavaScript Uplink Payload Formatter
In TTN Console → Payload formatters → Uplink, select Custom Javascript formatter and save the decoder below:
function decodeUplink(input) {
var bytes = input.bytes;
if (bytes.length < 6) {
return { errors: ["Payload must be at least 6 bytes"] };
}
// 1. Unpack Packet Counter
var counter = (bytes[0] << 8) | bytes[1];
// 2. Unpack Signed Temperature
var rawTemp = (bytes[2] << 8) | bytes[3];
if (rawTemp & 0x8000) rawTemp -= 0x10000;
var temperature = rawTemp / 100.0;
// 3. Unpack Humidity
var rawHum = (bytes[4] << 8) | bytes[5];
var humidity = rawHum / 100.0;
return {
data: {
packet_counter: counter,
temperature_c: temperature,
humidity_rh: humidity
}
};
}
The TTN Live Data tab will immediately display the decoded telemetry:
{
"packet_counter": 1,
"temperature_c": 25.42,
"humidity_rh": 65.2
}
Step 5: Receiving a Downlink (+EVT:RX)
LoRaWAN Class A end devices can receive downlink data from the network during their RX1 or RX2 receive windows following an uplink:
- In the TTN Console, open your device → Navigate to Messaging → Downlink.
- Set FPort to
10. - Set Payload type to Bytes, and enter a 1-byte command (e.g.,
01to turn on an actuator). - Click Schedule downlink.
- Return to your Serial Monitor and transmit your next telemetry packet:
AT+SEND=10:000209EF1970
OK
+EVT:TX_DONE
+EVT:RX:10:-68.0:9.5:01
The modem reports the downlink event with port number (10), signal strength (RSSI: -68.0 dBm), signal-to-noise ratio (SNR: 9.5 dB), and the raw payload bytes (01)!
6. Troubleshooting Common AT Modem Issues
| Symptom / Error | Root Cause | Verified Solution |
|---|---|---|
No response when typing AT | Serial baud rate mismatch or incorrect line ending settings. | 1. Ensure Serial Monitor is set to 115200 baud.2. Set line ending dropdown in Arduino IDE to Both NL & CR or Newline.3. Verify the USB-C cable supports data and is not charge-only. |
AT_PARAM_ERROR on AT+DEVEUI or AT+APPKEY | Input string contains non-hex characters or incorrect character count. | 1. AT+DEVEUI and AT+APPEUI must contain exactly 16 hexadecimal characters (0-9, A-F).2. AT+APPKEY must contain exactly 32 hexadecimal characters.3. Ensure no trailing spaces or punctuation are included. |
AT_NO_NETWORK_JOINED when running AT+SEND | The modem has not successfully completed an OTAA join session. | Execute AT+JOIN first and wait for +EVT:JOINED before issuing AT+SEND. Verify activation status with AT+NJS=?. |
+EVT:JOIN_FAILED (Error -1107) or (Error -705) | Receiver timed out waiting for JoinAccept during RX1/RX2 windows. | 1. Check DIO1 Wire: The SX1276 DIO1 pin must be connected to ESP32 GPIO 27. If missing, the MCU misses the RX window timeout.2. Check Gateway: Verify the Cubicore Gateway Pocket or Hub is online in the same TTN cluster ( au1).3. Verify Keys: Confirm DevEUI, AppEUI, and AppKey match TTN byte-for-byte. |
DevNonce is too small in TTN Live Data | Device presented an old nonce after reflashing or hardware replacement. | 1. In TTN Console → Device → Settings → Network layer → click Reset used DevNonces. 2. In the Serial Monitor, execute AT+DEVNONCE=RESET to clear the modem's stored NVS buffer.3. Run AT+JOIN again. |
| First packet received by gateway, then all subsequent packets disappear | Adaptive Data Rate (ADR) commanded the node away from dual-channel frequencies. | The Cubicore Gateway Pocket and Hub only listen on DR2 (SF10). Execute AT+ADR=0 followed by AT+DR=2. Check settings with AT+STATUS=?. |
Next Steps
Now that you have a fully operational, serial-commanded LoRaWAN modem, explore related guides:
- LoRaWAN Guides Overview — Explore the broader architecture, transceiver comparisons, and guide roadmap.
- SX1276 LoRaWAN with RadioLib — Chapter 2: Discrete C++ firmware implementation and microcontroller selection deep dive.
- Integrating with Cubicore Cloud — Route your TTN decoded JSON payloads directly into Cubicore Cloud telemetry dashboards.
- Cubicore Devboard Guide Series — Step-by-step guides for our integrated, all-in-one ESP32-S3 + SX1262 IoT development platform.