Heltec Wireless Stick Lite – wysyłanie danych do LoRaWAN – część 1

Heltec Wireless Stick to moduł z ESP32 i modułem radiowym SX1262 – czyli LoRa. A jak LoRa to i LoRaWAN jest możliwy :) Heltec, producent tych modułów oferuje własny zestaw bibliotek do połączenia się z LoRaWAN. Jednak w Nettigo zdecydowanie jesteśmy fanami rozwiązań open source. Niniejszym przedstawiam wam przykład jak korzystać z Helteca w LoRaWAN.

Uwaga – te artykuły odnoszą się do Heltec Wireless Stick Lite v3. Ta płytka ma procesor ESP32-S3 i radio SX1262. Poprzednie wersje korzystały z wcześniejszych ESP32 z inna architekturą i mają inny układ radia LoRa (SX1276). Biblioteki zwykle nie obsługują obu, więc ten przykład nie zadziała z SX1276.

Na początek mały disklajmer. LoRa jest ciekawym tematem, czasem o niego się ocieram, ale nie czuję się specjalistą. Spróbuję wam wytłumaczyć jak ja rozumiem te zagadnienia, ale jeżeli dostrzegasz jakiś błąd – śmiało powiedz o tym w komentarzu.

Pierwsza sprawa. LoRa a LoRaWAN. LoRa to zestaw protokołów, technologi radiowych które pozwalają na komunikację między dwoma urządzeniami. Nazwa jest od LOng RAnge – i faktycznie LoRa pozwala na osiągnięcie komunikacji między oddalonymi urządzeniami (nawet na wiele kilometrów przy sprzyjających warunkach) przy użyciu niewielkiej ilości energii. Czyli daleko i tanio (energetycznie). Z czego trzeba zrezygnować? Z przepustowości, cudów nie ma. LoRa jest rozwiązaniem dla czujników wysyłających niewielkie ilości danych.

W oparciu o LoRa budowane są większe sieci, oferujące gotową infrastrukturę pomocniczną. Takie sieci nazywane są LoRaWAN i jedną z nich jest The Things Network (TTN). Wszystko w tej serii artykułów dotyczy TTN.

LoRa zapewnia komunikację miedzy dwoma urządzeniami. W sieciach LoRaWAN mamy podział na urządzenia końcowe (nodes), bramki (gateway), sieć (network). Urządzenia końcowe wysyłają wiadomości, które odebrane przez dowolną bramkę zostają przesłane do właściwej aplikacji wewnątrz TTN. Tam może być wiadomość przekierowana do zewnętrznego serwisu (jednego lub wielu).

TTN jest publiczną, otwartą siecią, każdy z niej może korzystać. Aby pozostała użyteczna na darmowych użytkowników są nałożone ograniczenia – liczba urządzeń w sieci. Również ograniczony jest czas przez jaki urządzenia mogą nadawać. Nazywa się to 'Fair use policy’ i w skrócie – każde urządzenie może w ciągu 24h nadawać maksymalnie przez 30 s. W zależności od ilości danych przesyłanych w wiadomości oraz parametrów nadawania (w uproszczeniu im bliżej bramki tym możemy mniej czasu zajmuje nadanie naszej wiadomości) oznacza to że urządzenie może wysyłać dane kilka razy na godzinę (mało bajtów, blisko do bramki) do kilku transmisji na dobę. Więcej o Fair use policy na stronach TTN.

Czyli od razu widzimy, że pierwszy warunek korzystania z LoRaWAN to jest to czy jesteśmy w zasięgu sieci. Jak to sprawdzić? TTN mapper to serwis który pokazuje (na podstawie fizycznie odebranych pakietów oznaczonych lokalizacją nadawcy) rzeczywisty zasięg sieci.

Jeśli nie jesteś w zasięgu, a poważnie rozważasz „wejście w LoRaWAN” to może postawisz własną bramkę?Bramka Heltec HT-M01S LoRaWAN w zasadzie przeznaczona jest do pracy wewnątrz pomieszczenia, ale ma antenę (na ok metrowym kablu) z magnetyczna podstawką, którą można umieścić na zewnątrz, poprawiając zasięg. Jedyny warunek to dostęp do internetu – bramka musi mieć ciągłą łączność z siecią.

Na początek – rejestracja w TTN

Jak wspomniałem w sieci LoRaWAN pracuje wiele urządzeń. Specyfikacja protokołu używanego pozwala na bezpieczne przekazywanie danych (szyfrowanie) w ramach jednej aplikacji. Na początku trzeba założyć konto na TTN, potem utworzyć aplikację , a w niej dodać urządzenie. Jeśli masz własną bramkę – ją też musisz dodać w TTN. Bramkę dodajesz raz, wszystkie aplikacje/urządzenia mogą korzystać z każdej bramki.

Gdy to zrobisz, zaprogramujesz swoje urządzenie to wiadomości z niego będą się pojawiały na konsoli twojej aplikacji.

Właśnie wpadło…

Po kolei – idź na stronę TTN znajdź przycisk SignUp. Wybierz plan, zakładam że wszyscy czytający ten poradnik skorzystają z darmowego Community Edition/Individual. Po zakończeniu procesu tworzenia konta, zaloguj się i kliknij swój awatar/nazwę w prawym górnym rogu ekranu. Przejdź do konsoli (Console). W chwil pisania poradnika TTN ma 3 klastry: Europa, Ameryka Północna i Australia. My wszyscy pewnie będziemy korzystać z europejskiego, ale kto wie, może jakiś kangur też nas czyta :)

Wybierz menu Applications i kliknij przycisk Add application. Wypełnij wymagane dane, czyli application id.

Nowa aplikacja właśnie się tworzy

Po jej utworzeniu konsola przekieruje cię na stronę aplikacji

Naszym następnym krokiem jest utworzenie nowego urządzenia. Idź do menu End devices i wybierz Register new device

Wybierz z bazy urządzeń HelTec Automation/Wireless Stick Lite class A, OTAA. W Polsce używamy pasma EU_863_870. JoinEUI – na ile rozumiem dokumentację, dla urządzeń takich jak nasz stick, które sami programujemy JoinEUI można utworzyć z samych zer.

Istnieją dwa rodzaje pracy w sieci ABP i OTAA. Pierwsza to Activation By Personalization – po prostu w konsoli generujesz wszystkie klucze aplikacyjne i „wklejasz” je w kod. Choć teoretycznie prostsza w naszym przypadku, nie jest zalecaną. W razie jakiś problemów, konieczności ponownego regenerowania kluczy urządzenie trzeba zaprogramować ponownie. OTAA to Over The Air Activation i jak tutaj sama nazawa wskazuje urządzenie „samo” się przyłącza do sieci przez wysłanie specjalnego żądania Join request.

Teraz kolej na wygenerowanie kluczy, które pozwolą dołączyć urządzeniu do sieci TTN. Pamiętaj, że są to dane poufne, każdy kto ma te klucze może zarejestrować urządzenie, „wywalając” twoje z sieci. DevEUI oraz AppKey możesz wygenerować automatycznie (przycisk Generate)

O ile dobrze rozumiem zasady „unikalności” End device ID musi być unikalny w twojej aplikacji – może się powtarzać w kolejnych aplikacjach.

OK, podsumujmy. By aktywować swoje urządzenie w sieci potrzebujesz:

  • JoiunEUI nazywane też AppEUI
  • DevEUI
  • AppKey

Będziemy je kopiowac później, by wkeić w nasz kod. Zwróć uwagę, że po najechaniu kursorem na pola z tym wartościami pojawia się ikona na zmianę formatu danych na „przyjazny” do wklejenia w kod (wartości oddzielone przecinkami)

Podstawowa łączność – przykładowy kod

Urządzenie w aplikacji jest utworzone, potrzebujemy teraz wgrać oprogramowanie na naszego Helteca. Mój wybór padł na SX126x-Arduino jako bibliotekę obsługującą LoRaWAN.

Stan na listopad 2024 jest taki, że Arduino IDE 2.x nie potrafi skompilować tej biblioteki w wersji 2.x (a takiej będziemy potrzebowali). Problem tkwi w definicjach płytki Heltec, którą ma Arduino IDE. Ma ona na sztywno zaszyte opcje do kompilacji tejże i niestety, ale wersja 2.x nie skompiluje się. Na szczęście (choć nie testowałem tego dogłębnie – skompilował się kod to uznałem za sukces) można zmodyfikować plik definicji płytek w Arduoino IDE. Odnajdź boards.txt w pakiecie Arduino-ESP32 i znajdź tam definicje płytki Heltec Wirelss Stick Lite v3, a dokładniej definicję menu wybierającą region (zmienne REGION_XXXXX) Dodaj jakiś nieistniejący region – wtedy spowoduje on że ten warunek w kodzie biblioteki nie „odpali” i uda się kompilacja.

Ale, jak pisałem już tutaj chyba nie raz jestem fanem PlatfomIO, które możesz używać z linii poleceń albo z różnymi IDE (VSCode, CLion). Idealnie tutaj też nie jest :) bo z kolei płytka v3 (inny procesor!) nie ma jeszcze swojej definicji. Ale inna płytka Helteca która już korzysta z ESP32-S3 jest, więc wystarczy ustawić board = heltec_wifi_lora_32_V3 (Heltec WiFi LoRa 32 V3) w platformio.ini i „będzie pan zadowolony„.

Kod (do ściągnięcia tutaj w formie projektu PaltfomIO) dziś tutaj prezentowany to praktycznie „goły” i gotowy przykład z biblioteki LoRaWAN, okrojony tylko z kodu dla innych architektur niż ESP32. W następnych częściach będziemy na tym bazować.

Kilka słów wyjaśnień dotyczących kodu.

Tutaj parę objaśnień. Po pierwsze definicje pinów użytych przez moduł radia. Jeśli chciałbyś przystosować ten kod dla innych płytek, tutaj zapewne będzie potrzebna modyfikacja.

// ESP32 - SX126x pin configuration
int PIN_LORA_RESET = 12;	 // LORA RESET
int PIN_LORA_NSS = 8;	 // LORA SPI CS
int PIN_LORA_SCLK = 9;	 // LORA SPI CLK
int PIN_LORA_MISO = 11;	 // LORA SPI MISO
int PIN_LORA_DIO_1 = 14; // LORA DIO_1
int PIN_LORA_BUSY = 13;	 // LORA SPI BUSY
int PIN_LORA_MOSI = 10;	 // LORA SPI MOSI
int RADIO_TXEN = -1;	 // LORA ANTENNA TX ENABLE
int RADIO_RXEN = -1;	 // LORA ANTENNA RX ENABLE

Rzecz, która nie jest potrzebna w Arduino IDE, ale „normalnie” jest potrzebna – deklaracje wyprzedzające (forward declaration) przed użyciem funkcji albo ona musi zostać normalnie zadeklarowana albo musi pojawić się jej „nagłówek”:

static void lorawan_has_joined_handler(void);
static void lorawan_rx_handler(lmh_app_data_t *app_data);
static void lorawan_confirm_class_handler(DeviceClass_t Class);
static void lorawan_join_failed_handler(void);
static void send_lora_frame(void);
static uint32_t timers_init(void);

Te funkcje (zdefiniowane dalej w kodzie) są używane do konfiguracji biblioteki. lorawan_has_joined_handler to funkcja która zostanie wywołana po udanym podłączeniu do sieci w procesie OTAA. lorawan_rx_handler to funkcja, która zostanie wywołana gdy urządzenie otrzyma wiadomość z sieci (to jest tak zwany downlink, wiadomości wysyłane z urządzenia do sieci LoRaWAN nazywane są uplink).

Jak zapewne pamiętasz podczas rejestracji urządzenia podaliśmy że to urządzenie klasy A. W LoRaWAN są trzy klasy urządzeń A, B i C. Urządzenie może zmieniać swoją klasę w jakiej pracuje. Jeśli się to zdarzy zostanie wywołana funkcja lorawan_confirm_class_handler. Klasa urządzenia określa jak się ono zachowuje w sieci, jak długo jest aktywne – innymi słowy jak łatwo się z nim skomunikować. Klasa A oznacza przez większość czasu urządznie może być offline i włączać się tylko gdy ma coś do wysłania. Jak widać jest to optymalny tryb pracy dla różnego rodzaju sensorów.

lorawan_join_failed_handler zostanie wywołana gdy Join request nie doszedł do skutku. Dlaczego? Zwykle albo coś pomieszałeś z kluczami albo nie ma zasięgu.

send_lora_frame to funkcja robiąca właściwą robotę, czyli wysyła dane.No dobra, ta funkcja (oraz timers_init) z listy deklaracji wyprzedzającej nie jest wymagana przez samą biblotekę, ale wymagane ze względu na logikę kodu w przykładzie. Twórca założył, że dane do sieci LoRaWAN będą wysyłane periodycznie. send_lora_frame to funkcja która fizycznie nadaje pakiet przez SX1262.W funkcji timers_init uruchamiany jest zegar, który wywoła tx_lora_periodic_handler. Ta funkcja wywołuje send_lora_frame i ponownie ustawia timer. SX1262-Arduino ma implementację takich timerów. Mozesz jej sie przyjrzeć, może znajdziesz zastosowanie w swoich projektach.

Czy zawsze periodyczne wysyłanie danych ma sens? Nie, są pomiary które są asynchroniczne – dobrym przykładem może być pomiar opadu deszczu. Jak jest to jest, jak nie pada to nie ma zbytnio co wysyłać. Może raz na dzień ACK że stacja nadal żyje.

uint8_t nodeDeviceEUI[8] = {0x70, 0xB3, 0xD5, 0x7E, 0xD0, 0x06, 0xB8, 0xB9};
uint8_t nodeAppEUI[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t nodeAppKey[16] = {0xF1, 0x37, 0x4B, 0xE1, 0x3B, 0x99, 0xC5, 0xCE, 0x7D, 0x1C, 0x16, 0x82, 0xEF, 0xEC, 0xF1, 0xAC};

Te trzy zmienne zawierają dane niezbędne do wykonania OTAA. Oczywiście kopiujesz je z konsoli swojej aplikacji. Tylko te 3 wartości wymagają aktualizacji by kod przykładu mógł pracować w twojej aplikacji TTN.

Pozostaje go załadować na Heltec Wireles Stick Lite (pio run -t upload dla tych co CLI używają) i obserwować konsolę w zakładce Live data. Powinno pojawić się coś takiego:

Hura! Jesteśmy online!

Wróćmy do kodu, który fizycznie wysłał dane, czyli send_lora_frame:

    if (lmh_join_status_get() != LMH_SET)
    {
        //Not joined, try again later
        Serial.println("Did not join network, skip sending frame");
        return;
    }

Nie podłączyliśmy się do sieci? Nie ma co wysyłać.

    uint32_t i = 0;
    m_lora_app_data.port = LORAWAN_APP_PORT;
    m_lora_app_data.buffer[i++] = 'H';
    m_lora_app_data.buffer[i++] = 'i';
    m_lora_app_data.buffer[i++] = '!';
    m_lora_app_data.buffsize = i;

    lmh_error_status error = lmh_send(&m_lora_app_data, LMH_UNCONFIRMED_MSG);
    Serial.printf("lmh_send result %d\n", error);

Tak o to powstaje prosty komunikat do wysłania. LoRa nie jest dla transmisji tekstowej! Zazwyczaj będziesz korzystał z czegoś jak Cayenne LPP (zajmiemy się tym w dalszych częściach) jako format wiadmości. Wpisałem tutaj proste ’Hi!’ bo na przykładzie chcę Ci pokazać pewne narzędzia jakie są w konsoli TTN.

Tak więc, jeśli kod załadowany po jakimś czasie zdefiniowanym w LORAWAN_APP_TX_DUTYCYCLE (3 minuty) powinny w konsoli pojawić się wiadomości z twojego urządzenia. Kliknij w nią by rozwinąć szczegóły.

Pojedynczy uplink widziany w konsoli
Szczegóły wiadomości

Świetnie, nasza wiadomość, ale gdzie są dane? Wpisdata w JSON zawiera dane urządzenia nadawczego a sama wiadomość (tak zwany payload) jest w polu – frm_payload. Czemu są tam jakieś hieroglify a nie ’Hi!’?

Transmisja w LoRaWAN jest szyfrowana – to po pierwsze. Po drugie konsola TTN nie ma pojęcia w jakim formacie wysyłasz dane, więc wyświetla je nieprzetworzone w ogóle. TTN zna klucze które zostały wygenerowane w trakcie OTAA, więc to nie tutaj tkwi problem. Pozostaje porzekazać jaki jest format danych. Służą do tego Payload formatters. Możesz zdefiniować go globalnie, wtedy obsłuży wszystkie uplinki (albo downlinki bo są dwa rodzaje formaterów) albo dla każdego urządzenia oddzielnie. Zasada działania jest taka sama, więc pokażę taki formater na poziomie aplikacji.

Jak widzisz gdybyśmy już Cayenne używali można wybrać taki format i koniec robota zrobiona. Ale my may jakiś nietypowy format danych. Dlatego dla nas jest Custom Javascript formatter. JS używana przez konsolę dla formaterów to ES5.1. Nie ma dostępu do sieci, żadnego reqire maksymalny rozmiar to 40 kB. Jeśli potrzebujesz wiedzieć więcej śmiało szukaj na TTN.

Tworzymy prostą funkcję, która zamienia ciąg bajtów w łańcuch znakowy (input to obiekt z danym wejściowymi):

  let str = input.bytes
    .map((byte) => {
        return String.fromCharCode(byte);
    })
    .join("");

Formater ma zwrócić hash z zdekodowanym ładunkiem i ewentualnymi ostrzeżeniami/błedami.

  return {
    data: {
      bytes: input.bytes,
      str: str
    },
    warnings: [],
    errors: []
  };
}

Konsola TTN chce by ten kod został owinięty w funkcję o nazwie decodeUplink z input będącym jej argumentem, a atrybut bytes jest tablicą z zawartością ładunku. Cały formater więc wygląda tak:


function decodeUplink(input) {
  let str = input.bytes
    .map((byte) => {
        return String.fromCharCode(byte);
    })
    .join("");

  return {
    data: {
      bytes: input.bytes,
      str: str
    },
    warnings: [],
    errors: []
  };
}

Po zapisaniu formatera dla uplinków, następna przychodząca wiadomość wygląda już inaczej:

Skrócony widok wiadomości
Wszystkie szczegóły wiadomości

Co dalej?

Kod z tego przykładu jest do ściągnięcia tutaj (w formie projektu PlatformIO).

W następnej części zajmiemy się tym jak sprawić by nasz patyk (Stick) nie musiał po każdym restarcie żądać dołączenia do sieci LoRaWAN, tylko korzystał z kluczy wynegocjowanych poprzednio. Jest to ważne, bo po pierwsze nie obciąża sieci niepotrzebnymi żądaniami join, po drugie jest bardziej niezawodne.

Dlaczego niezawodne? Jak sama natura procesu wskazuje konieczna do jego zakończenia jest komunikacja dwukierunkowa między bramką a urządzeniem końcowym. Musi on przecież odebrać klucze, które będą użyte do szyfrowania. Antena i odbiornik w „kijku” zwykle mają nieco gorszą czułość niż te, które są w bramkach. Dlatego, często urządzenie może wysłać wiadomość do bramki (join dotrze) ale już urządzenie końcowe nie potrafi odebrać odpowiedzi z bramki (klucze nie dotrą do naszego Helteca).

Restart urządzenia blisko granicy zasięgu oznacza, że OTAA nie powiedzie się a bez kluczy odebranych przez urządzenie końcowe – nie ma szans na łączność.

Ale ten problem rozwiążemy już w następnej części…