LoRa Basic Modem auf dem SAMD21G18 mit PlatformIO

Semtech's LoRa Basic Modem (LBM / SWL2001) ist der aktuelle Stand der Technik für LoRaWAN-Endgeräte: MAC 1.0.4, vollständige Duty-Cycle- und ADR-Verwaltung, Grundlage für die LoRa-Alliance-Zertifizierung. Und es ist der Stack, den Semtech selbst für die Zertifizierung des SX1262 einsetzt — das ist bei CE- und LoRaWAN-Zertifizierung kein unwichtiges Argument. sx126x-Arduino hat bekannte Grenzen: MAC 1.0.2, zwei Bugs im Join-Handling, kein echtes Class C. RadioLib ist ausgezeichnet für direkten Radio-Zugriff — aber kein vollständiger LoRaWAN-Stack. LBM schliesst diese Lücke.

Nahaufnahme eines Semtech SX1262 LoRaWAN Chips.

Die Zielplattform ist kein Eval-Kit: ein eigenes Board auf Basis des SAMD21G18, das wir selbst entwickelt und inhouse bestückt haben — SX1262-Referenzschaltung nach Semtech-Datenblatt, Prototyp aus dem eigenen Reflow-Ofen. Es wird die Basis für unsere Produktions-Designs. PlatformIO als Build-System, da Semtechs eigene HAL-Beispiele auf STM32/Keil/CMake zielen.

Was folgt, ist der Portierungsweg — mit allen Stolpersteinen.

LBM ist keine Arduino-Library

LBM liefert weder eine library.json noch eine src/-Struktur, die PlatformIO automatisch erkennt. Die einzige funktionierende Lösung: ein pre:-Script, das alle LBM-Quelldateien und Include-Pfade manuell in die Build-Umgebung injiziert.

Import("env")
from pathlib import Path

LBM  = Path(env["PROJECT_DIR"]).parent / "libs" / "SWL2001" / "lbm_lib"
CORE = LBM / "smtc_modem_core"
HAL  = Path(env["PROJECT_DIR"]).parent / "libs" / "lbm_samd21" / "src"

env.Append(CPPPATH=[
    str(LBM / "smtc_modem_api"),
    str(LBM / "smtc_modem_hal"),
    str(CORE / "lr1mac" / "src"),
    str(CORE / "radio_drivers" / "sx126x_driver" / "src"),
    str(HAL),
    # ... weitere LBM-interne Pfade
])

lbm_srcs = [
    "smtc_modem_core/smtc_modem.c",
    "smtc_modem_core/lr1mac/src/lr1mac_core.c",
    "smtc_modem_core/lr1mac/src/lr1mac_class_c/lr1mac_class_c.c",
    "smtc_modem_core/smtc_modem_test.c",  # auch ohne Test-Mode erforderlich
    # ... ca. 30 Dateien insgesamt
]

for src in lbm_srcs:
    env.Append(PIOBUILDFILES=[
        env.Object(
            target=str(Path("$BUILD_DIR") / "lbm" / Path(src).stem) + ".o",
            source=str(LBM / src),
        )
    ])

Die HAL-Dateien (smtc_modem_hal.cpp, sx126x_hal.cpp, ral_sx126x_bsp.cpp) werden im selben Script als C++-Objekte kompiliert — nur so können sie Arduino-APIs wie SPI1, millis() und attachInterrupt() verwenden.

Build-Flags: Jedes Detail zählt

build_flags =
    -DREGION_EU_868
    -DSX126X           ; NICHT -DSX1262 — kollidiert mit RadioLib's Klassenname
    -DNB_OF_STACK=1    ; beide Schreibweisen nötig — LBM nutzt sie intern
    -DNUMBER_OF_STACKS=1
    -DADD_CLASS_C      ; NICHT -DLBM_CLASS_C=1 — LBM prüft #ifdef, nicht #if
    -DSOFTWARE_MODEM_CRYPTO
    -DSMTC_MODEM_TRACE=1
    -DMODEM_HAL_UNIT_TEST=0

Drei Fallen, die still zu falschen Binaries führen:

 

Das HAL — drei Dateien, viel Detailarbeit

TC3: 1-ms-Timer für MAC-Events

LBM benötigt einen Hardware-Timer für RX-Fenster, Duty-Cycle-Scheduling und ADR. Auf dem SAMD21 ist TC3 im Arduino-Kontext frei. GCLK0 (48 MHz) durch Prescaler 64 ergibt 750 kHz — CC0 = 749 produziert exakt 1 ms Overflow. Ein Software-Zähler streckt das auf die Minuten-Perioden, die LBM intern plant.

static void _tc3_init(void) {
    GCLK->CLKCTRL.reg = GCLK_CLKCTRL_ID_TCC2_TC3 |
                        GCLK_CLKCTRL_GEN_GCLK0    |
                        GCLK_CLKCTRL_CLKEN;
    while (GCLK->STATUS.bit.SYNCBUSY);

    TC3->COUNT16.CTRLA.reg = TC_CTRLA_SWRST;
    while (TC3->COUNT16.STATUS.bit.SYNCBUSY);

    TC3->COUNT16.CTRLA.reg = TC_CTRLA_MODE_COUNT16 |
                             TC_CTRLA_WAVEGEN_MFRQ  |
                             TC_CTRLA_PRESCALER_DIV64;

    TC3->COUNT16.CC[0].reg = 749;  // 750 kHz / 750 = 1 ms
    TC3->COUNT16.INTENSET.reg = TC_INTENSET_OVF;
    NVIC_SetPriority(TC3_IRQn, 3);
    NVIC_EnableIRQ(TC3_IRQn);
    TC3->COUNT16.CTRLA.reg |= TC_CTRLA_ENABLE;
}

static volatile uint32_t  _tc3_ticks_remaining = 0;
static          void    (*_tc3_callback)(void *) = nullptr;
static          void     *_tc3_context           = nullptr;

extern "C" void TC3_Handler(void) {
    TC3->COUNT16.INTFLAG.reg = TC_INTFLAG_OVF;
    if (_tc3_ticks_remaining == 0) return;
    if (--_tc3_ticks_remaining == 0 && _tc3_callback)
        _tc3_callback(_tc3_context);
}

void smtc_modem_hal_start_timer(const uint32_t ms,
                                 void (*cb)(void *), void *ctx) {
    _tc3_init();
    NVIC_DisableIRQ(TC3_IRQn);
    _tc3_callback        = cb;
    _tc3_context         = ctx;
    _tc3_ticks_remaining = ms ? ms : 1;
    NVIC_EnableIRQ(TC3_IRQn);
}

SPI1.begin() — der stille Killer

Wer einen expliziten SPIClass& übergibt — sei es an RadioLib oder direkt an den LBM SPI-HAL — bekommt intern initInterface = false gesetzt. Der Stack ruft spi->begin() nie selbst auf. Alle SPI-Reads liefern 0xFF, findChip() gibt -2 zurück, ohne weitere Erklärung.

void RadioControllerLBM::init(void) {
    pinMode(Config::SX_NSS_PIN,   OUTPUT); digitalWrite(Config::SX_NSS_PIN,   HIGH);
    pinMode(Config::SX_RESET_PIN, OUTPUT); digitalWrite(Config::SX_RESET_PIN, HIGH);
    pinMode(Config::SX_BUSY_PIN,  INPUT);

    SPI1.begin();  // muss manuell vor smtc_modem_init() aufgerufen werden

    smtc_modem_init(on_modem_event);
    // DevEUI / JoinEUI / NwkKey setzen...
}

NVM-Kontexte: das Row-Erase-Problem

Der SAMD21 löscht Flash immer zeilenweise — eine Row umfasst 4 × 256 B = 1 KB. LBM speichert vier Kontexte (Modem, Keys, LoRaWAN-Stack, Secure Element), je eine Page. Naives Löschen einer Row würde die anderen drei Kontexte vernichten. Die Lösung: Shadow-Buffer der gesamten Row im RAM, Ziel-Page patchen, Row löschen, alle vier Pages zurückschreiben.

static uint8_t _nvm_row_shadow[FLASH_ROW_SIZE];  // 1 KB RAM-Buffer

void smtc_modem_hal_context_store(const modem_context_type_t ctx_type,
                                   uint32_t offset,
                                   const uint8_t *buffer, const uint32_t size) {
    uint32_t row_base = FLASH_SIZE - FLASH_ROW_SIZE;  // letzte 1 KB

    memcpy(_nvm_row_shadow, (const uint8_t *)row_base, FLASH_ROW_SIZE);

    uint32_t page_off = (uint32_t)_ctx_to_page(ctx_type) * FLASH_PAGE_SIZE + offset;
    memcpy(_nvm_row_shadow + page_off, buffer, size);

    _nvm_erase_row(row_base);
    for (uint32_t p = 0; p < 4; p++)
        _nvm_write_page(row_base + p * FLASH_PAGE_SIZE,
                        _nvm_row_shadow + p * FLASH_PAGE_SIZE, FLASH_PAGE_SIZE);
}

millis() stoppt im Deep Sleep

LBMs Duty-Cycle-Manager basiert auf smtc_modem_hal_get_time_in_ms(). Auf dem SAMD21 läuft millis() über SysTick — der stoppt im STANDBY-Modus. Ohne Kompensation denkt LBM nach dem Aufwachen, keine Zeit sei vergangen, und berechnet das Duty-Cycle-Budget falsch.

static uint32_t _time_offset_ms = 0;

void smtc_modem_hal_add_time_offset(uint32_t ms) { _time_offset_ms += ms; }

uint32_t smtc_modem_hal_get_time_in_ms(void) { return millis() + _time_offset_ms; }

RadioControllerLBM::sleep() addiert die tatsächliche Schlafzeit vor dem STANDBY-Eintritt.

Join-Loop: einmal aufrufen, LBM das Retry überlassen

Eine verbreitete Falle: smtc_modem_join_network() in einer eigenen Retry-Schleife aufzurufen. LBM gibt beim zweiten Aufruf SMTC_MODEM_RC_BUSY zurück und loggt intern "already join" — ohne dass etwas passiert. Einmal aufrufen, smtc_modem_run_engine() in einer Schleife drehen, JOINFAIL-Events zählen.

mtc_modem_join_network(0);  // genau einmal — LBM plant alle Retries selbst

while (millis() - t0 &lt; JOIN_MAX_RETRIES * JOIN_TIMEOUT_MS) {
    smtc_modem_run_engine();
    if (ev_joined)    return STATUS_OK;
    if (ev_join_fail) {
        ev_join_fail = false;
        if (++fails >= JOIN_MAX_RETRIES) break;
        // LBM kümmert sich um Backoff (22 s -> 152 s -> ...) — einfach weiterdrehen
    }
}

ChirpStack: das automatische Class-C-Switching

Wer ein LoRaWAN-1.0.4-Geräteprofil mit "allow class C" aktiviert hat, erlebt eine Überraschung: ChirpStack schaltet das Gerät automatisch auf CLASS_C um — ab v4.16.2 nach dem ersten Uplink, ab v4.18.0 bereits beim Join. LBM sendet in 1.0.x kein DeviceModeInd (das kam erst mit 1.1.x); ChirpStack entscheidet also einseitig.

Das Problem: LBM implementiert strikte Class-A-RX-Fenster. Downlinks mit immediately-Timing, die ChirpStack für CLASS_C-Geräte sendet, kommen in einem Class-A-RX1/RX2-Fenster nie an. Murata und RadioLib haben das jahrelang maskiert — durch zufällig breitere Empfangsfenster.

Die Lösung: Für Class-A-Geräte ein Profil ohne "allow class C" verwenden. Class-C-Geräte smtc_modem_set_class(0, SMTC_MODEM_CLASS_C) erst nach erfolgreichem Join aufrufen — davor gibt die API still SMTC_MODEM_RC_FAIL zurück.

Ergebnis

Erster OTA-Join erfolgreich. RSSI −50 dBm, SNR 15 dB. Der Stack läuft stabil, die nächste Stufe ist die Integration in die Produktions-Firmware.

Der HAL sind drei Dateien, rund 400 Zeilen C++. Die eigentliche Arbeit steckt in den Stellen, die schweigen: ein falscher Define, ein vergessenes SPI1.begin(), ein Row-Erase ohne Shadow-Buffer. Genau das ist der Unterschied zwischen einer Woche und einem Monat.

Die HAL-Portierung für Arduino/PlatformIO werden wir möglicherweise auf GitHub veröffentlichen — eine saubere LBM-Basis für den SAMD21 existiert bisher nicht öffentlich, und der Nutzen für die Community wäre real. Ob wir uns damit auch die Maintenance-Last einhandeln wollen, ist noch offen.