Skip to main content

SX1276 AT Command LoRaWAN Modem (RAK RUI3-Style)

Firmware Target & Architecture

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 StepHardcoded C++ FirmwareRUI3-Style AT Command Modem
Step 1: ConfigurationEdit C++ byte arrays in source codeFlash firmware ONCE via USB
Step 2: DeploymentCompile sketch (45–60 s) & flash over USBOpen Serial Monitor at 115200 baud
Step 3: Credential UpdateRecompile & re-flash entire binarySend: AT+DEVEUI=70B3D5... & AT+APPKEY=A1B2...
Step 4: Network JoinHardcoded in setup()Send: AT+JOIN (dynamic interactive join)
Step 5: TelemetryHardcoded periodic timerSend: AT+SEND=10:01020304
Device Swap Time2–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 .bin file across hundreds of boards, then commission unique DevEUI and AppKey credentials 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 PinESP32 DevKit PinFunctionNotes
VCC / 3.3V3V33.3V PowerMust connect to 3.3V rail. Do NOT connect to 5V or VIN!
GNDGNDGroundCommon ground reference.
SCKGPIO 18VSPI ClockSPI Serial Clock.
MISOGPIO 19VSPI MISOMaster In / Slave Out (Data from SX1276 to ESP32).
MOSIGPIO 23VSPI MOSIMaster Out / Slave In (Data from ESP32 to SX1276).
NSS / CSGPIO 5SPI Chip SelectActive-low chip select.
RESET / RSTGPIO 14Hardware ResetActive-low transceiver reset.
DIO0GPIO 26Interrupt 0Mandatory: Signals TxDone and RxDone.
DIO1GPIO 27Interrupt 1Mandatory: Signals RxTimeout for LoRaWAN RX1/RX2 windows.
ANTSMA Bulkhead50Ω RF OutputConnect tuned 868/915 MHz antenna before powering on!
Wiring & RF Safety Reminders
  1. Both DIO0 and DIO1 are Mandatory: Unlike raw LoRa P2P examples which only use DIO0, LoRaWAN Class A operation strictly requires DIO1 to detect when receive windows timeout. Leaving DIO1 disconnected will cause every AT+JOIN attempt to fail with +EVT:JOIN_FAILED_RX_TIMEOUT.
  2. 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_BOOST output 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), or Both 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​

CategoryCommandParametersDescription & Behavior
SystemATNoneBasic AT ping. Returns OK if modem firmware is responsive.
SystemAT? or AT+HELPNoneDisplays the formatted quick reference command directory.
SystemAT+STATUS=?NoneDisplays a comprehensive overview of all credentials, RF settings, and network join status.
SystemATZNoneTriggers an immediate software reboot via ESP.restart().
SystemATRNoneFactory reset: Erases all "at_modem" NVS namespaces and restarts the ESP32.
IdentityAT+DEVEUI=?NoneQueries the current 64-bit Device EUI formatted as a 16-character hex string.
IdentityAT+DEVEUI=<hex>16 hex charsConfigures a new 64-bit Device EUI (e.g., AT+DEVEUI=70B3D57ED007940B). Saves to NVS.
IdentityAT+APPEUI=?NoneQueries the current 64-bit JoinEUI / AppEUI.
IdentityAT+APPEUI=<hex>16 hex charsConfigures the 64-bit JoinEUI (e.g., AT+APPEUI=0000000000000000). Saves to NVS.
IdentityAT+APPKEY=?NoneQueries the current 128-bit Application Root Key (32 hex characters).
IdentityAT+APPKEY=<hex>32 hex charsConfigures the 128-bit AES AppKey. Sets both appKey and nwkKey for LoRaWAN 1.0.4. Saves to NVS.
RF / MACAT+BAND=?NoneQueries the active regional band ID (10 = AS923-3).
RF / MACAT+BAND=<id>10, 8, 4, 5Selects regional parameters: 10 = AS923-3, 8 = AS923-1, 4 = EU868, 5 = US915. Saves to NVS.
RF / MACAT+ADR=?NoneQueries Adaptive Data Rate state (0 = Off, 1 = On).
RF / MACAT+ADR=<0|1>0 or 10: Disabled (Required for Cubicore dual-channel gateways).
1: Enabled (Standard 8-channel multi-channel networks).
RF / MACAT+DR=?NoneQueries the current Data Rate index (0 to 5).
RF / MACAT+DR=<0..5>0 to 5Sets the transmission Datarate. 2 = SF10 BW125 (Required for Cubicore dual-channel gateways).
RF / MACAT+TXP=?NoneQueries the configured RF transmit power in dBm.
RF / MACAT+TXP=<p>2 to 20Sets output power in dBm (Default 20 via PA_BOOST). Saves to NVS.
RF / MACAT+CFM=?NoneQueries uplink confirmation mode (0 = Unconfirmed, 1 = Confirmed).
RF / MACAT+CFM=<0|1>0 or 1Sets confirmation mode: 0 for unconfirmed uplinks, 1 for acknowledged uplinks.
NetworkAT+JOINNone or =1Initiates an OTAA activation exchange with TTN. Returns OK followed by asynchronous event.
NetworkAT+NJS=?NoneNetwork Join Status: Returns AT+NJS=1 if currently joined, or AT+NJS=0 if disconnected.
NetworkAT+SEND=<p>:<hex><port>:<hex>Sends variable-length hex payload on LoRaWAN port 1..223. Returns OK followed by +EVT:TX_DONE.
LedgerAT+DEVNONCE=?NoneQueries whether a saved DevNonce ledger exists in NVS.
LedgerAT+DEVNONCE=RESETNoneClears 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 IndexSpreading FactorBandwidthIndicative BitrateAirtime (6-Byte Payload)Gateway Compatibility
DR0SF12125 kHz250 bps~1155 msStandard 8-Channel Concentrator only
DR1SF11125 kHz440 bps~577 msStandard 8-Channel Concentrator only
DR2SF10125 kHz980 bps~164 msCubicore Gateway Pocket & Gateway Hub (Mandatory Default)
DR3SF9125 kHz1760 bps~82 msStandard 8-Channel Concentrator only
DR4SF8125 kHz3125 bps~46 msStandard 8-Channel Concentrator only
DR5SF7125 kHz5470 bps~26 msStandard 8-Channel Concentrator only
Dual-Channel Gateway Rule

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 TypeReturn Codes / EventsExecution Timing & Behavior
Synchronous ResponsesOK
AT_ERROR
AT_PARAM_ERROR
AT_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 successful AT+JOIN.

Asynchronous Event Codes​

  • +EVT:JOINING: The transceiver has initiated the OTAA JoinRequest transmission.
  • +EVT:JOINED: JoinAccept was 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., -1107 for 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​

  1. Log into your The Things Network Console (Select cluster au1 for Asia/Philippines).
  2. Open your Application → Click + Register end device → Select Enter end device specifics manually.
  3. 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
  4. Under Provisioning:
    • JoinEUI: 0000000000000000
    • DevEUI: Click Generate (e.g., 70B3D57ED007940B)
    • AppKey: Click Generate (e.g., 112233445566778899AABBCCDDEEFF00)
  5. Click Register end device.
No C-Array Formatting Required!

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

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
}

LoRaWAN Class A end devices can receive downlink data from the network during their RX1 or RX2 receive windows following an uplink:

  1. In the TTN Console, open your device → Navigate to Messaging → Downlink.
  2. Set FPort to 10.
  3. Set Payload type to Bytes, and enter a 1-byte command (e.g., 01 to turn on an actuator).
  4. Click Schedule downlink.
  5. 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 / ErrorRoot CauseVerified Solution
No response when typing ATSerial 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+APPKEYInput 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+SENDThe 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 DataDevice 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 disappearAdaptive 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: