Heltec Wireless Stick Lite v3 – jedno OTAA by związać wszystkie – część 2

W pierwszej części tej serii dowiedzieliśmy się jak stworzyć aplikację i dodać urządzenie do sieci TTN (LoRaWAN). Zaczęliśmy też wysyłać pierwsze wiadomości. Nasz mały Heltec, którego do tego celu wykorzystujemy jako pierwszy krok musi dołączyć do sieci przez OTAA. OTAA to proces uzgadniania i wymiany kluczy do szyfrowania. Jak wynika z opisu konieczna jest wymiana wiadomości między urzadzeniem a bramką, co oznacza że w momencie włączenia urządzenie końcowe musi być nieco bliżej bramki. Zazwyczaj lepszą antenę/układ odbiorczy ma bramka i jest w stanie odebrać wiadomość od urządzenia końcowego. Ale z kolei, urządzenie końcowe nie jest już w stanie odebrać odpowiedzi od bramki – jego antena/układ odbiorczy nie są wystarczająco czułe. W takiej sytuacji proces OTAA nie kończy się sukcesem.

Co więcej – TTN wyraźnie mówi, że ty jako autor oprogramowania powinien unikać zbędnych żądań join. TTN ma własne, dobre powody by tego wymagać, jak więc to osiągnąć?

OTAA vs ABP

Aktywacja over the air oznacza, że wysyłamy żądanie dołączenia do sieci a ta odpowiada podając adres urządzenia oraz klucze do szyfrowania. ABP (Activation By Personalization) jest trybem w którym takie same klucze i adres są generowane w konsoli i ręcznie muszą być „wklejone” w kod działający na urządzeniu. To znaczy zwykle konieczność wygenerowania nowej wersji pliku binarnego i jego wgranie lub jakaś konfiguracja przez sieć WiFi.

Po udanej aktywacji OTA zasada komunikacji jest taka sama jak w trybie ABP – więc pozostaje tylko kwestia utrwalenia kluczy otrzymanych z sieci TTN. W przypadku ESP32 najprostsze wydaje się skorzystanie z biblioteki Preferences. Korzysta ona z wydzielonego obszaru w pamięci flash. Zwykle jest on o rozmiarze 16 kB, jednak ESP32 używa go do zapisywania swoich wewnętrznych danych (głównie WiFi) więc nie możesz liczyć, że taki rozmiar jest dostępny. Jednak gdy mówimy o przechowywaniu niewielkich zestawów danych, właśnie takich jak klucze – świetnie do tego się nadaje.

Jak działają Preferences w ESP32?

Preferences to zbiór zapisów typu klucz/wartość, opakowanych w nazwaną przestrzeń. Co to znaczy?

#include <Preferences.h>
Preferences appConfig;

void setup(){
  appConfig.begin("lorawan");
}

Oczywiście trzeba najpierw włączyć bibliotekę przez #include Preferences.h by mieć dostęp do potrzebnych funkcji. Następnie tworzysz obiekt typu Preferences. Zanim cokolwiek więcej zrobisz, trzeba otworzyć przestrzeń nazw przez begin("namespace"). Domyślnie jest ona otwarta w trybie zapis/odczyt ale możesz dodać argument true by była otwarta w trybie tylko do odczytu.

W dwóch różnych przestrzeniach nazw możesz mieć zapisane dwa takie same klucze mające przypisaną różną wartość, a nawet będące różnego typu. W ten sposób możesz tworzyć różne profile aplikacji – wystarczy zmienić przestrzeń nazw otwieraną na początku (np po restarcie) i aplikacja może się zupełnie inaczej zachowywać.

Ładowanie kodu nie nadpisuje tego obszaru pamięci flash, więc zachowane parametry będą dostępne jeśli np wgrasz sobie nowszą wersję kodu.

Przestrzeń jest otwarta, ale jak zapisać dane? Biblioteka oferuje funkcje do zapisu różnych typów danych. W dokumentacji znajdziesz tabelę z listą wszystkich dostępnych typów danych. Na przykład, by zapisać wartość typu unsigned int: appConfig.putUInt("cycles-no",24); To utworzy klucz cycles-no w otwartej przestrzeni nazw i zapisze wartość 24. Funkcja zwróci liczbę bajtów zapisanych. Pamiętaj by sprawdzać czy nie zwróciła 0 – to by znaczyło, że z jakiegoś powodu zapis się nie udał (np. brak miejsca, przestrzeń otwarta tylko do odczytu).

By odczytać zapisaną wartość użyj unsigned int cycles = appConfig.getUInt("cycels-no",10) 10 jest wartością która zostanie zwrócona jeśli np nie ma takiego klucza w przestrzeni nazw. Ten parametr jest opcjonalny, dla liczbowych typów danych domyślnie będzie to 0.

Pamiętaj by funkcje użyte do zapisu/odczytu były takie same. Zapisujesz – UInt – odczytuj przez getUInt. Jeśli twój kod z jakiegoś powodu może spodziewać się różnych typów danych appConfig.getType("keyname") zwróci typ danych zapisanych pod tym kluczem (tabla z wartościami zwracanymi przez funkcję w dokumentacji).

Dane typu int, float mają stały rozmiar. Co z string’ami lub tablicami bajtów? Np dla tych drugich, dla zapisu jest funkcja putBytes. Tablica uint8_t NetworkSessionKey[16] – by ją zapisać w preferencjach użyj appConfig.putBytes("ntwsk", NetworkSessionKey, 16). Pierwszy argument to nazwa klucza (każdy klucz może mieć maksymalnie 15 znaków i składać się z liczb, cyfr i chyba myślnika), następnie wskaźnik na tablicę oraz jej rozmiar. Funkcja zwróci liczbę zapisanych bajtów. Znowu – sprawdzaj czy nie 0, bo to znaczy nieudany zapis. Np. taki kod:

    if ( !appConfig.putBytes("ntwsk", NetworkSessionKey, 16)){
        appConfig.remove("ntwsk");
        return false;
    };

Pamiętasz że ! neguje wartości logiczne? Wartość różna od zera (zapisano bajty) traktowana jest jako prawda, więc zanegowana wartość zwrócona przez putBytes będzie to false. Kod w bloku po if się nie wykona. Odwrotnie – gdy funkcja zwróci zero, czyli nieudany zapis, zanegowana wartość to będzie true.

Ja założyłem, że jeżeli zapis się nie udał (np brak miejsca) to wolę usunąć w ogóle klucz, by miec pewnośc że nie zostaną dane zachowane z poprzedniego zapisu.

Odczytywanie danych do tablicy – getBytes – by odczytać tablicę z poprzedniego przykładu użyj appConfig.getBytes("ntwsk", NetworkSessionKey, 16) – klucz, wskaźnik na tablicę i jej rozmiar. Funkcja zwróci liczbę odczytanych bajtów lub zero jeśli coś poszło nie tak.

Jednym z powodów błędu może być zbyt mały bufor do którego chcesz zapisać dane z preferencji. Tutaj docelowa tablica ma rozmiar 16 bajtów. Wiemy że taki będzie bo taki zapisujemy, ale co jeśli twój kod musi sobie radzić z danymi o różnych rozmiarach? Użyj appConfig.getBytesLength("keyname") by dowiedzieć się ile tam jest zapisanych bajtów.

OK, by dopełnić opis biblioteki jeszcze kilka użytecznych funkcji. By sprawdzić czy keyname jest zapisane w bieżącej przestrzeni użyj appConfig.isKey("keyname"). Klucz i wartość usuniesz używajac appConfig.remove("keyname"), lub „poleć po całości” kasując wszystkie klucze i zapisy w bieżącej przestrzeni: appConfig.clear(). Po szczegóły odsyłam do opisu Preferences API.

I jeszcze jedna rzecz – jak usunąć całą przestrzeń nazw? Są dwie metody, pierwsza to skorzystanie bezpośrednio z funkcji udostępnianych przez API ESP32 (nie Arduino ESP32) i sformatuj całą partycję NVS (ta która przechowuj preferencje, nie niszczy to innych danych w pamięci flash):

#include <nvs_flash.h>

void setup()
{
    Serial.begin(115200);
    Serial.print("Clearing NVS flash...");
    nvs_flash_erase();      // erase the NVS partition and...
    nvs_flash_init();       // initialize the NVS partition.
    Serial.println("\n\nDone. Upload regular code.");
    while(true){delay(1);}
}
void loop(){
}

Wgraj ten kod, uruchom raz i wgraj coś innego – inaczej każdy restart będzie formatował ten kawałek flasha.

Druga opcja to wykasować całość pamięci flash narzędziem esptool.py. Na Ubuntu jeśli Wireless Stick jest podpięty jako USB0: esptool.py --port /dev/ttyUSB0 erase_flash. To działanie czyści wszystko – kod, ewentualny system plików i NVS.

Jesteśmy gotowi do zapisywania i odczytywania, ale czego?

Jak pewnie pamiętasz z poprzedniego wpisu JoinEUI i nodeAppKey są wspólne dla wszystkich urządzeń w danej aplikacji, samo urządzenie wyróżnione jest przez nodeDeviceEUI. Po udanym OTAA potrzebne są trzy parametry – klucze sesji sieciowe (network session key) oraz sesji aplikacyjnej (application session key) oraz adres urządzenia. Jeśli kodowałeś kiedyś połączenie typu ABP te nazwy powinny być dobrze znajome, bo to są te dane generowane w konsoli.

Po udanym OTAA trzeba zapisać te dwa klucze i adres urządzenia w preferncjach i kolejną inicjalizację radia przeprowadzić w trybie ABP z użyciem tych danych. Klucze są 16-sto bajtowe a adres jest liczbą 32bitową. Jak je pozyskać? Po udanym OTA, a więc w lorawan_has_joined_handler skorzystać z:

    lmh_getAppSkey(AppSessionKey);
    lmh_getNwSkey(NetworkSessionKey);

gdzie AppSessionKey oraz NetworkSessionKey są globalnymi zmiennymi – tablice 16 bajtów. Adres urządzenia jest zwracany przez lmh_getDevAddr().

Będziemy używać następującej logiki programu:

  • na starcie otworzyć przestrzeń nazw preferencji
  • spróbuj odczytać adres urządzenia i klucze sesyjne
  • jeśli odczyt był udany – zrób lmh_join w trybie ABP z użyciem tych kluczy. W przeciwnym razie wyślij join request w OTAA
  • jeśli proces OTAA zakończył się sukcesem – zapisz klucze/adres do użycia przy następnym starcie urządzenia

Przegląd kodu

Skoncentruję się na zmianach w stosunku do przykładu z części pierwszej.

Tworzymy appConfig – globalną zmienną do obsługi prferencji, w setup otwieramy przestrzeń nazw, ja wybrałem sobie nazwę lorawan. Następnie próbujemy w get_stored_keys odczytać dane:

static bool get_stored_keys(void){
    if (!appConfig.isKey("ntwsk") || !appConfig.isKey("appsk") || !appConfig.isKey("devaddr"))
        return false;
    if (!appConfig.getBytes("ntwsk", NetworkSessionKey, 16))
        return false;
    if (!appConfig.getBytes("appsk", AppSessionKey, 16))
        return false;
    nodeDevAddr = appConfig.getUInt("devaddr");
    return true;
}

Pierwszy krok – spradzamy czy wszystkie trzy klucze są zapisane. Jeśli któregoś brak, uznajemy proces odczytu za nieudany, nie ustawiamy żadnych zmiennych.

Jak używamy tej funkcji w setup?

 if (get_stored_keys()){
        OTAA_request = false;
        Serial.println("Keys restored from storage");
        lmh_setAppSKey(AppSessionKey);
        lmh_setNwkSKey(NetworkSessionKey);
        lmh_setDevAddr(nodeDevAddr);
        print_keys();
    }

W razie udanego odczytu – ustawiamy klucze sesyjne i adres urządzenia korzystajac z funkcji biblioteki SX126x-Arduino lmh_setXXXX. Skoro jesteśmy gotowi do pracy w trybie ABP ustawiamy też globalną flagę OTAA_request na false (ustawionej domyślnie na true). Zostanie ona uzyta w lmh_init jako trzeci parametr, określający czy korzystać z OTAA czy ABP:

    err_code = lmh_init(&lora_callbacks, lora_param_init, OTAA_request, CLASS_A, LORAMAC_REGION_EU868);

Pozostaje zapisanie danych przy pierwszym uruchomieniu. W lorawan_has_joined_handler dodajemy funkcje lmh_getXXXX by zapisać zmienne sesyjne w tablicach i wywołujemy store_keys(), która zapisze wszystkie dane:

    lmh_getAppSkey(AppSessionKey);
    lmh_getNwSkey(NetworkSessionKey);
    print_keys();
    store_keys();

A sama funkcja do zapisu kluczy wygląda tak (fragment jej wcześniej wklejałem):

static boolean store_keys() {
    if ( !appConfig.putBytes("ntwsk", NetworkSessionKey, 16)){
        appConfig.remove("ntwsk");
        return false;
    };
    if (!appConfig.putBytes("appsk", AppSessionKey, 16)) {
        appConfig.remove("appsk");
        return false;
    };
    if (appConfig.putUInt("devaddr",lmh_getDevAddr()) == 0) {
        appConfig.remove("devaddr");
        return false;
    }
    return true;
}

Po wgraniu kodu na twoje urządzenie powinieneś zauważyć, że przy pierwszym uruchomieniu na konsoli pojawi się kolejne join request, ale potem kolejne restarty nie powinny generować już tego komunikatu. Twój kod jest po pierwsze zgodny z wytycznymi TTN a po drugie lepiej sobie będzie radził w dużej odległości od bramki. W razie problemów z aktywacją – podjedź gdzieś bliżej do bramki, dokonaj aktywacji i jest szansa że będzie wysyłał dane z dużej odległości.

To tyle na dziś, w następnej części zajmiemy się wysyłaniem prawdziwych danych, a konkretnie lokalizacji z modułu GPS i wysyłanie tych danych do TTN Mapper by zobaczyć jak nasze urządzenie jest w stanie się komunikować z siecią.

Na koniec – kod z tego przykładu jest do pobrania tutaj w formie projektu PlatformIO.