Thanks for the information.
I used the files available in the current version of RUI3 (V4.2.x), which are not included in the build, and tried to set up the remote multicast configuration.
I’m now able to get the multicast setup ready in AWS and successfully send a scheduled downlink. However, I’m not able to receive the payload at the scheduled time.
Could you please have a look at my code and let me know if you notice anything that might be causing the issue?
/*
* RAK3172 (RUI3) — AWS IoT Core for LoRaWAN automated multicast setup
* =====================================================================
* CONFIRMED against your actual firmware (AT+VER = RUI_4.2.4_RAK3172-E):
* this version's source does contain the LmHandler-based TS005/TS003/
* TS004 packages (service_lora_multicast.c, LmhpRemoteMcastSetup.c),
* gated behind #ifdef SUPPORT_FUOTA — but boards.txt never defines
* SUPPORT_FUOTA for any RAK3172 board on this release, AND
* LmHandlerInit() is never called anywhere in service_lora.c on this
* version (it only starts appearing, fully wired up, from RUI3 v5.0.0
* onward). So on your exact firmware, that native path is unfinished/
* inert — not just a flag you could safely force on — and this
* application-level implementation is the path that actually works.
*
* (If a firmware update to v5.0.0+ is viable later, that path is
* worth revisiting — it removes the need for this file entirely. Not
* something to gate your current deadline on, though.)
*
* Adds the LoRaWAN Remote Multicast Setup package (TS005, fPort 200)
* and a minimal Application Layer Clock Sync responder (TS003, fPort
* 202) on top of your join/Class-C skeleton, so the device answers
* AWS's automated multicast negotiation instead of silently dropping
* it (which is what stock RUI3 does).
*
* BEFORE YOU FLASH THIS, FILL IN / CHECK:
* 1. node_gen_app_key[] below — the LoRaWAN 1.0.x root key AWS uses
* to derive your device's multicast Key-Encryption-Key. Must
* match what's on file in AWS IoT Core for LoRaWAN for this device.
* 2. aes128_encrypt() / aes128_decrypt() at the bottom — wire these
* to a *verified* AES-128 ECB implementation (whatever you used
* to get to "Multicast setup ready" before, or a well-tested
* library like kokke/tiny-AES-c). Getting this wrong produces
* the wrong session keys silently — no error, it just won't
* decrypt multicast downlinks. Check it against known AES-128
* test vectors before trusting it.
* 3. REGION below — set to match your actual gateway deployment
* (your notes say US915 for the SenseCAP M2; the RAK_REGION_IN865
* in your snippet looks like leftover placeholder).
* 4. One-time cleanup: if this board previously had a manually
* configured multicast group (AT+ADDMULC) from earlier testing,
* clear it once via the serial monitor with AT+RMVMULC=<addr>
* before running this.
*/
#include <Arduino.h>
#include <string.h>
// ---------------- LoRaWAN OTAA credentials ----------------
// Replace below parameter with DEVEUI, APPEUI, APPKEY(Use AT command AT+DEVEUI=?, AT+APPEUI=? and AT+APPKEY=? to get the keys).
uint8_t node_device_eui[8] = {0xAC, 0x1F, 0x09, 0xFF, 0xFE, 0x19, 0xDD, 0x60};
uint8_t node_app_eui[8] = {0xAC, 0x1F, 0x09, 0xFF, 0xF8, 0x65, 0x33, 0x72};
uint8_t node_app_key[16] = {0xAC, 0x1F, 0x09, 0xFF, 0xFE, 0x19, 0xDD, 0x60,
0xAC, 0x1F, 0x09, 0xFF, 0xF8, 0x65, 0x33, 0x72};
// Root key used ONLY for multicast key derivation (TS005 §4.3, LoRaWAN
// 1.0.x scheme). Fill this in to match AWS.
// GenAPPKEY is Same as APPKEY
uint8_t node_gen_app_key[16] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
#define REGION RAK_REGION_IN865 // match your gateway's region plan
// ---------------- fPorts (TS005 / TS003 defaults) ----------------
static const uint8_t FPORT_MULTICAST_SETUP = 200; // Remote Multicast Setup (TS005)
static const uint8_t FPORT_CLOCK_SYNC = 202; // Application Layer Clock Sync (TS003)
// ================================================================
// Multicast group state
// ================================================================
#define MC_MAX_GROUPS 4
struct McGroupContext {
bool inUse = false;
uint32_t mcAddr = 0;
uint8_t mcAppSKey[16] = {0};
uint8_t mcNwkSKey[16] = {0};
uint32_t minFCount = 0;
uint32_t maxFCount = 0;
};
McGroupContext mcGroups[MC_MAX_GROUPS];
// Remote Multicast Setup CIDs (TS005, package ID 2)
enum : uint8_t {
MC_PACKAGE_VERSION = 0x00,
MC_GROUP_STATUS = 0x01,
MC_GROUP_SETUP = 0x02,
MC_GROUP_DELETE = 0x03,
MC_CLASSC_SESSION = 0x04,
};
// Clock Sync CIDs (TS003, package ID 1)
enum : uint8_t {
CLK_PACKAGE_VERSION = 0x00,
CLK_APP_TIME = 0x01,
CLK_DEVICE_APP_PERIODICITY = 0x02,
CLK_FORCE_RESYNC = 0x03,
};
// ---------------- little-endian helpers (all TS003/TS005 multi-byte fields are LE on air) ----------------
static uint32_t le32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static void put_le32(uint8_t *p, uint32_t v) {
p[0] = v & 0xFF; p[1] = (v >> 8) & 0xFF; p[2] = (v >> 16) & 0xFF; p[3] = (v >> 24) & 0xFF;
}
// ================================================================
// Multicast key derivation — TS005 §4.3
// McRootKey = aes128_encrypt(GenAppKey, 0x00 | pad16)
// McKEKey = aes128_encrypt(McRootKey, 0x00 | pad16)
// McKey = aes128_decrypt(McKEKey, McKey_encrypted) <- true AES decrypt
// McAppSKey = aes128_encrypt(McKey, 0x01 | McAddr | pad16)
// McNwkSKey = aes128_encrypt(McKey, 0x02 | McAddr | pad16)
// ================================================================
extern bool aes128_encrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
extern bool aes128_decrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
static void deriveMcSessionKeys(const uint8_t genAppKey[16],
const uint8_t mcKeyEncrypted[16],
uint32_t mcAddr,
uint8_t mcAppSKeyOut[16],
uint8_t mcNwkSKeyOut[16]) {
uint8_t zeroBlock[16] = {0};
uint8_t mcRootKey[16], mcKEKey[16], mcKey[16];
aes128_encrypt(genAppKey, zeroBlock, mcRootKey);
aes128_encrypt(mcRootKey, zeroBlock, mcKEKey);
aes128_decrypt(mcKEKey, mcKeyEncrypted, mcKey);
uint8_t block[16] = {0};
block[0] = 0x01;
put_le32(&block[1], mcAddr);
aes128_encrypt(mcKey, block, mcAppSKeyOut);
block[0] = 0x02;
put_le32(&block[1], mcAddr);
aes128_encrypt(mcKey, block, mcNwkSKeyOut);
}
// ================================================================
// Remote Multicast Setup handler (fPort 200)
// ================================================================
void handleMulticastSetup(uint8_t *payload, uint16_t len) {
uint8_t resp[64];
uint16_t respLen = 0;
uint16_t i = 0;
while (i < len) {
uint8_t cid = payload[i++];
switch (cid) {
case MC_PACKAGE_VERSION: {
// PackageVersionReq has no payload (Table 2)
resp[respLen++] = MC_PACKAGE_VERSION;
resp[respLen++] = 2; // PackageIdentifier = 2 (multicast control package)
resp[respLen++] = 1; // PackageVersion = 1
break;
}
case MC_GROUP_SETUP: {
// Req (Table 7): McGroupIDHeader(1) McAddr(4) McKeyEncrypted(16) minFCount(4) maxFCount(4) = 29 bytes
if (i + 29 > len) { i = len; break; }
uint8_t groupId = payload[i] & 0x03; i += 1;
uint32_t mcAddr = le32(&payload[i]); i += 4;
uint8_t mcKeyEncrypted[16];
memcpy(mcKeyEncrypted, &payload[i], 16); i += 16;
uint32_t minFCount = le32(&payload[i]); i += 4;
uint32_t maxFCount = le32(&payload[i]); i += 4;
bool idError = (groupId >= MC_MAX_GROUPS);
if (!idError) {
McGroupContext &ctx = mcGroups[groupId];
ctx.inUse = true;
ctx.mcAddr = mcAddr;
ctx.minFCount = minFCount;
ctx.maxFCount = maxFCount;
deriveMcSessionKeys(node_gen_app_key, mcKeyEncrypted, mcAddr, ctx.mcAppSKey, ctx.mcNwkSKey);
// Keys are derived and stored here, but not registered with
// RUI3 yet — api.lorawan.addmulc() also needs frequency/data
// rate, which only arrives with McClassCSessionReq below.
Serial.printf("MC group %d set up, addr 0x%08lX\r\n", groupId, (unsigned long)mcAddr);
}
resp[respLen++] = MC_GROUP_SETUP;
// McGroupSetupAns (Table 9/10): RFU(5) IDerror(1) McGroupID(2)
resp[respLen++] = (idError ? 0x04 : 0x00) | (groupId & 0x03);
break;
}
case MC_GROUP_DELETE: {
// Req (Table 11/12): McGroupIDHeader(1) = 1 byte
if (i + 1 > len) { i = len; break; }
uint8_t groupId = payload[i] & 0x03; i += 1;
bool undefined = !mcGroups[groupId].inUse;
if (!undefined) {
api.lorawan.rmvmulc(mcGroups[groupId].mcAddr);
mcGroups[groupId] = McGroupContext();
}
resp[respLen++] = MC_GROUP_DELETE;
// McGroupDeleteAns (Table 13/14): RFU(5) MCGroupUndefined(1) McGroupID(2)
resp[respLen++] = (undefined ? 0x04 : 0x00) | (groupId & 0x03);
break;
}
case MC_CLASSC_SESSION: {
// Req (Table 15-17): McGroupIDHeader(1) SessionTime(4) SessionTimeOut(1) DLFrequ(3) DR(1) = 10 bytes
if (i + 10 > len) { i = len; break; }
uint8_t groupId = payload[i] & 0x03; i += 1;
uint32_t sessionTime = le32(&payload[i]); i += 4;
i += 1; // SessionTimeOut (TimeOut nibble) — not enforced here, TODO if you need it
uint32_t dlFreqRaw = (uint32_t)payload[i] | ((uint32_t)payload[i + 1] << 8) | ((uint32_t)payload[i + 2] << 16);
i += 3;
uint8_t dr = payload[i]; i += 1;
uint32_t dlFreqHz = dlFreqRaw * 100UL;
bool groupUndefined = !mcGroups[groupId].inUse;
// TODO: validate dlFreqHz / dr fall within your region's channel
// plan and set these accordingly instead of hardcoding false.
bool freqError = false;
bool drError = false;
uint32_t nowGpsEpoch = getGpsEpochSeconds();
int32_t timeToStart = groupUndefined ? 0 : (int32_t)(sessionTime - nowGpsEpoch);
if (timeToStart < 0) timeToStart = 0;
if (!groupUndefined && !freqError && !drError) {
McGroupContext &ctx = mcGroups[groupId];
RAK_LORA_McSession session;
session.McDevclass = 2; // Class C
session.McAddress = ctx.mcAddr;
memcpy(session.McAppSKey, ctx.mcAppSKey, 16);
memcpy(session.McNwkSKey, ctx.mcNwkSKey, 16);
session.McFrequency = dlFreqHz;
session.McDatarate = dr;
session.McPeriodicity = 0; // required even for Class C per RAK's own tutorial
session.McGroupID = groupId;
session.entry = groupId;
// Defensive: clear anything already registered at this address
// first. A leftover group from earlier testing is exactly what
// blocked replies before — don't let a stale entry linger here.
api.lorawan.rmvmulc(ctx.mcAddr);
if (!api.lorawan.addmulc(session)) {
Serial.println("addmulc failed");
} else {
Serial.printf("MC group %d session armed: %lu Hz, DR%d, start in %ld s\r\n",
groupId, (unsigned long)dlFreqHz, dr, (long)timeToStart);
}
}
resp[respLen++] = MC_CLASSC_SESSION;
// McClassCSessionAns (Table 18/19): RFU(3) McGroupUndefined(1) FreqError(1) DRError(1) McGroupID(2)
uint8_t status = ((groupUndefined ? 1 : 0) << 4) |
((freqError ? 1 : 0) << 3) |
((drError ? 1 : 0) << 2) |
(groupId & 0x03);
resp[respLen++] = status;
resp[respLen++] = timeToStart & 0xFF;
resp[respLen++] = (timeToStart >> 8) & 0xFF;
resp[respLen++] = (timeToStart >> 16) & 0xFF;
break;
}
default:
// Unknown CID for this package — stop parsing this message.
i = len;
break;
}
}
if (respLen > 0) {
api.lorawan.send(respLen, resp, FPORT_MULTICAST_SETUP, false);
}
}
// ================================================================
// Application Layer Clock Sync (fPort 202) — minimal responder
// ================================================================
static uint32_t deviceGpsEpochOffset = 0; // seconds
static uint32_t deviceClockSetAtMillis = 0;
uint32_t getGpsEpochSeconds() {
return deviceGpsEpochOffset + (millis() - deviceClockSetAtMillis) / 1000UL;
}
void sendAppTimeReq(bool ansRequired) {
uint8_t payload[6];
static uint8_t tokenReq = 0;
tokenReq = (tokenReq + 1) & 0x0F;
payload[0] = CLK_APP_TIME;
put_le32(&payload[1], getGpsEpochSeconds());
payload[5] = (tokenReq & 0x0F) | (ansRequired ? 0x10 : 0x00);
api.lorawan.send(6, payload, FPORT_CLOCK_SYNC, false);
}
void handleClockSync(uint8_t *payload, uint16_t len) {
uint8_t resp[8];
uint16_t respLen = 0;
uint16_t i = 0;
while (i < len) {
uint8_t cid = payload[i++];
switch (cid) {
case CLK_PACKAGE_VERSION:
resp[respLen++] = CLK_PACKAGE_VERSION;
resp[respLen++] = 1; // PackageIdentifier = 1 (clock sync package)
resp[respLen++] = 1; // PackageVersion = 1
break;
case CLK_APP_TIME: {
// This direction is the *answer*: TimeCorrection(4, signed) + Param(1)
if (i + 5 > len) { i = len; break; }
int32_t correction = (int32_t)le32(&payload[i]); i += 4;
i += 1; // TokenAns — not cross-checked here for simplicity
deviceGpsEpochOffset = getGpsEpochSeconds() + correction;
deviceClockSetAtMillis = millis();
Serial.printf("Clock corrected by %ld s\r\n", (long)correction);
break; // no uplink reply for AppTimeAns itself
}
case CLK_FORCE_RESYNC: {
if (i + 1 > len) { i = len; break; }
uint8_t nbTransmissions = payload[i] & 0x07; i += 1;
for (uint8_t n = 0; n < nbTransmissions; n++) {
sendAppTimeReq(false);
delay(1000 + random(0, 2000)); // spread retransmissions per spec guidance
}
break;
}
default:
i = len;
break;
}
}
if (respLen > 0) {
api.lorawan.send(respLen, resp, FPORT_CLOCK_SYNC, false);
}
}
// ================================================================
// Downlink dispatch
// ================================================================
void onReceiveDownlink(SERVICE_LORA_RECEIVE_T *data) {
Serial.printf("Downlink: port %d, %d bytes: ", data->Port, data->BufferSize);
for (int i = 0; i < data->BufferSize; i++) Serial.printf("%02X ", data->Buffer[i]);
Serial.println();
if (data->Port == FPORT_MULTICAST_SETUP) {
handleMulticastSetup(data->Buffer, data->BufferSize);
} else if (data->Port == FPORT_CLOCK_SYNC) {
handleClockSync(data->Buffer, data->BufferSize);
}
// else: your normal application downlink handling goes here
}
// ================================================================
// Setup / loop
// ================================================================
void setup() {
Serial.begin(115200);
delay(3000);
api.lorawan.deui.set(node_device_eui, 8);
api.lorawan.appeui.set(node_app_eui, 8);
api.lorawan.appkey.set(node_app_key, 16);
api.lorawan.band.set(REGION);
api.lorawan.njm.set(RAK_LORA_OTAA);
api.lorawan.registerRecvCallback(onReceiveDownlink); // register before joining
Serial.println("Joining...");
api.lorawan.join();
while (api.lorawan.njs.get() == 0) {
Serial.println("Waiting for join...");
delay(5000);
}
Serial.println("Joined!");
deviceClockSetAtMillis = millis(); // clock sync will correct this once AWS forces a resync
api.lorawan.deviceClass.set(RAK_LORA_CLASS_C); // continuous listening
Serial.println("Class C - listening for downlinks / multicast setup.");
}
void loop() {
delay(1000);
}
// ================================================================
// AES-128 ECB — WIRE THIS TO A VERIFIED IMPLEMENTATION.
// Both functions operate on exactly one 16-byte block, no padding.
// Do not ship the stubs below as-is — they will build but produce
// garbage keys with no obvious error.
// ================================================================
bool aes128_encrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) {
// TODO: replace with your existing AES-128 encrypt call, or a
// verified library (e.g. kokke/tiny-AES-c's AES_ECB_encrypt).
memset(out, 0, 16);
return false;
}
bool aes128_decrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) {
// TODO: replace with your existing AES-128 decrypt call, or a
// verified library (e.g. kokke/tiny-AES-c's AES_ECB_decrypt).
memset(out, 0, 16);
return false;
}