Wysyłanie danych w formacie JSON – przykład z UART

W poprzednim artykule przedstawiłem podstawowe informacje i założenia dotyczące formatu JSON. Omówiliśmy jego podstawowe cechy i zastosowania oraz możliwości wykorzystania w systemach wbudowanych, w tym w mikrokontrolerach. Dzisiaj chciałbym pokazać Ci, w jaki sposób można w przystępny sposób tworzyć i wysyłać polecenia w formacie JSON na przykładzie interfejsu UART.

Generowanie JSON – pierwsze podejście

Polecenie w formacie JSON to tak na prawdę zwykły string. Możemy zatem takie polecenie zbudować, definiując tablicę znaków i ewentualnie łącząc je ze sobą za pomocą takich funkcji z biblioteki standardowej, jak strcat/strncat.

Na początek chcemy wysłać prostą informację o tym, że wciśnięty został przycisk. Taki JSON może wyglądać np. w taki sposób:

{
    "event":"button_clicked"
}

Żeby wygenerować taki ciąg znaków wystarczy zadeklarować tablicę char[]:

char json_string[] = "{\"event\":\"button_clicked\"}";

Jak widać, pojawia się pierwszy problem – przed każdym cudzysłowem musimy dodać backslash, aby ciąg znaków został prawidłowo zinterpretowany. Teraz spróbujemy dodać numer przycisku zapisany jako liczba całkowita. Nasz JSON będzie teraz wyglądał tak:

{
    "event":"button_clicked",
    "number":1
}

W tym przypadku mamy do rozwiązania dwie kwestie. Po pierwsze musimy zamienić naszą liczbę na string. Po drugie połączyć wszystkie części w odpowiedni sposób. Żeby wyglądało to w miarę przejrzyście, podzielimy JSON-a na kilka części.

Na początku zadeklarujemy zmienne.

char json_string[128] = "";
char tmp[16] = "";
int button_number = 1;

Teraz otworzymy nawias klamrowy, czyli dodamy początek JSON-a.

strcat(json_string, "{");

Następnie dodamy obiekt typu string.

strcat(json_string, "\"event\":\"button_clicked\",");

Drugim elementem jest obiekt typu int. Najpierw zamienimy zmienną int na string za pomocą funkcji snprintf, a potem dodamy całość razem z nagłówkiem do JSON-a.

snprintf(tmp, 16, "\"number\":%d", button_number);
strcat(json_string, tmp);

Na koniec zamykamy JSON-a za pomocą nawiasu klamrowego.

strcat(json_string, "}");

Całość będzie wyglądała w ten sposób:

char json_string[128] = "";
char tmp[16] = "";
int button_number = 1;

strcat(json_string, "{");                                   //open object
strcat(json_string, "\"event\":\"button_clicked\",");       //add string

snprintf(tmp, 16, "\"number\":%d", button_number);
strcat(json_string, tmp);                                   //add int

strcat(json_string, "}");                                   //close object

Nie wygląda to źle, ale im więcej elementów w JSON-ie, tym bardziej będzie się komplikował temat. Dlatego spróbujmy napisać prostą bibliotekę do generowania JSON-ów.

Generowanie JSON – biblioteka

Standardowo, jak przy każdej mniej złożonej bibliotece, tworzymy dwa pliki. W dzisiejszym przykładzie będzie to plik json_genetaor.c oraz json_generator.h. W pliku json_generator.h umieszczamy deklarację struktury, która będzie obsługiwała proces generowania. W procesie tworzenia biblioteki uznałem, że warto umieścić w niej takie pola:

typedef struct {
  char *buffer;
  uint32_t size;
  uint32_t index;
  uint32_t depth;
} json_generator_t;

Pole buffer to wskaźnik na bufor danych. Nie chciałem zakładać tutaj konkretnej długości, dlatego dałem możliwość użytkownikowi stworzenia własnego bufora o dowolnym rozmiarze. Pole size przechowuje rozmiar bufora. Pola index i depth odpowiadają za prawidłowe tworzenie JSON-a – index wskazuje na aktualny koniec danych w buforze, a depth określa poziom zagnieżdżenia JSON-a. Za pomocą makra define JSON_MAX_DEPTH określamy dopuszczalne zagnieżdżenie.

W pliku json_generator.h musimy jeszcze stworzyć interfejs użytkownika. Znajdzie się w nim oczywiście funkcja inicjalizująca json_init() oraz funkcje potrzebne do tworzenia poszczególnych elementów JSON-a.

bool json_init(json_generator_t *generator, char *buffer, size_t size);
bool json_open_object(json_generator_t *generator);
bool json_close_object(json_generator_t *generator);
bool json_open_array(json_generator_t *generator);
bool json_close_array(json_generator_t *generator);
bool json_add_string(json_generator_t *generator, const char *key, const char *value);
bool json_add_integer(json_generator_t *generator, const char *key, int value);
bool json_add_boolean(json_generator_t *generator, const char *key, bool value);
bool json_add_null(json_generator_t *generator, const char *key);
bool json_end(json_generator_t *generator);

W funkcji json_init() resetujemy pola generatora oraz przypisujemy bufor danych i jego długość. Dodatkowo czyścimy bufor wypełniając go zerami.

bool json_init (json_generator_t *generator, char *buffer, size_t size) 
{
    if (generator == NULL || buffer == NULL || size == 0) 
    {
        return false;
    }

    generator->buffer = buffer;
    generator->size = size;
    generator->index = 0;
    generator->depth = 0;

    memset(buffer, 0, size);

    return true;
}

Funkcje json_open_object() oraz json_close_object() odpowiadają za dodanie do bufora nawiasu klamrowego (otwarcie lub zamknięcie nawiasu). Sprawdzamy w nich, czy nasz bufor nie jest pełny oraz czy nie przekroczymy założonego poziomu zagnieżdżenia. W funkcji json_close_object() dodatkowo obsługujemy znak przecinka na końcu JSON-a.

bool json_open_object (json_generator_t *generator)
{
    if (generator == NULL || generator->depth >= JSON_MAX_DEPTH) 
    {
        return false;
    }

    if (generator->index < generator->size) 
    {
        generator->buffer[generator->index++] = '{';
        generator->depth++;

        return true;
    }
    else
    {
        return false;
    }
}

bool json_close_object (json_generator_t *generator)
{
    if (generator == NULL || generator->depth == 0) 
    {
        return false;
    }

    if (generator->buffer[generator->index-1] == ',')
    {
        generator->index--;
    }

    if (generator->index < generator->size)
    {
        snprintf(generator->buffer + generator->index, generator->size - generator->index, "},");
        generator->index += 2;
        generator->depth--;

        return true;
    }
    else
    {
        return false;
    }
}

Tutaj zatrzymam się na chwilę, ponieważ obsługa znaku przecinka może być trochę problematyczna i niejasna. Zgodnie z budową formatu JSON, poszczególne obiekty powinny być oddzielone od siebie przecinkami. Ale jeżeli mam ostatni obiekt w grupie (za którym jest nawias klamrowy, albo kwadratowy), tego przecinka już nie powinno być. Spójrzmy na przykład z początku wpisu:

{
    "event":"button_clicked",
    "number":1
}

Po „event”:”button_clicked” jest przecinek, ale po „number”:1 już nie. Dodając kolejne elementy JSON-a automatycznie przecinek będziemy dodawali za każdym z nich. Dopiero w momencie, gdy chcemy zamknąć obiekt lub tablicę (array), wtedy ten przecinek usuwamy. Taka obsługa wydawała mi się najsensowniejsza i najbardziej logiczna od strony programu. Odpowiada za to warunek:

if (generator->buffer[generator->index-1] == ',')
{
    generator->index--;
}

Analogicznie do obiektów obsługujemy tablicę (array).

bool json_open_array (json_generator_t *generator)
{
    if (generator == NULL || generator->depth >= JSON_MAX_DEPTH) 
    {
        return false;
    }

    if (generator->index < generator->size) 
    {
        generator->buffer[generator->index++] = '[';
        generator->depth++;

        return true;
    }
    else
    {
        return false;
    }
}

bool json_close_array (json_generator_t *generator)
{
    if (generator == NULL || generator->depth == 0) 
    {
        return false;
    }

    if (generator->buffer[generator->index-1] == ',')
    {
        generator->index--;
    }

    if (generator->index < generator->size) 
    {
        snprintf(generator->buffer + generator->index, generator->size - generator->index, "],");
        generator->index += 2;
        generator->depth--;

        return true;
    }
    else
    {
        return false;
    }
}

Pozostało nam obsłużyć dodawanie pozostałych czterech typów danych w formacie JSON, czyli string, integer, bool i null. Do ich implementacji wykorzystałem znaną już funkcję snprintf z odpowiednimi modyfikatorami.

bool json_add_string (json_generator_t *generator, const char *key, const char *value)
{
    if (generator == NULL || key == NULL || value == NULL || generator->depth == 0) 
    {
        return false;
    }

    size_t key_length = strnlen(key, JSON_MAX_KEY_LENGTH);
    size_t value_length = strnlen(value, generator->size - generator->index);

    if ((generator->index + key_length + value_length + 6) < generator->size) 
    {
        snprintf(generator->buffer + generator->index, generator->size - generator->index, "\"%s\":\"%s\",", key, value);
        generator->index += key_length + value_length + 6;

        return true;
    }
    else
    {
        return false;
    }
}
bool json_add_integer (json_generator_t *generator, const char *key, int value)
{
    if (generator == NULL || key == NULL || generator->depth == 0) 
    {
        return false;
    }

    size_t key_length = strnlen(key, JSON_MAX_KEY_LENGTH);
    size_t value_length = snprintf(NULL, 0, "%d", value);

    if ((generator->index + key_length + value_length + 4) < generator->size) 
    {
        snprintf(generator->buffer + generator->index, generator->size - generator->index, "\"%s\":%d,", key, value);
        generator->index += key_length + value_length + 4;

        return true;
    }
    else
    {
        return false;
    }
}
bool json_add_boolean (json_generator_t *generator, const char *key, bool value)
{
    if (generator == NULL || key == NULL || generator->depth == 0) 
    {
        return false;
    }

    size_t key_length = strnlen(key, JSON_MAX_KEY_LENGTH);
    const char *bool_value = value ? "true" : "false";
    size_t value_length = strnlen(bool_value, 6);

    if ((generator->index + key_length + value_length + 4) < generator->size) 
    {
        snprintf(generator->buffer + generator->index, generator->size - generator->index, "\"%s\":%s,", key, bool_value);
        generator->index += key_length + value_length + 4;

        return true;
    }
    else
    {
        return false;
    }
}
bool json_add_null (json_generator_t *generator, const char *key)
{
    if (generator == NULL || key == NULL || generator->depth == 0)
    {
        return false;
    }

    size_t key_length = strnlen(key, JSON_MAX_KEY_LENGTH);

    if ((generator->index + key_length + 8) < generator->size) 
    {
        snprintf(generator->buffer + generator->index, generator->size - generator->index, "\"%s\":null,", key);
        generator->index += key_length + 8;

        return true;
    }
    else
    {
        return false;
    }
}

Każda z funkcji zwraca false, jeśli coś się nie uda, oraz true, jeśli powiedzie się dodanie elementu.

Na koniec zaimplementował jeszcze funkcję zamykającą JSON-a. Obsługuję w nim kwestię przecinka na końcu polecenia (zgodnie ze standardem nie powinno go być), a także dodaję znak końca ciągu za danymi.

bool json_end (json_generator_t *generator)
{
    if (generator == NULL || generator->depth != 0)
    {
        return false;
    }

    if (generator->buffer[generator->index-1] == ',')
    {
        generator->index--;
    }

    if (generator->index < generator->size)
    {
        generator->buffer[generator->index] = '\0';
        return true;
    }

    return false;
}

Wysyłanie JSON za pomocą UART – przykład na Nucleo-L476RG

W celu sprawdzenia działania biblioteki przygotujemy prosty przykład z użyciem komunikacji szeregowej przez interfejs UART. Projekt wygenerujemy przy pomocy bibliotek HAL-a i generatora CubeMX dla płytki Nucleo-L476RG.

Tworzymy nowy projekt za pomocą polecenia File->New->STM32 Project. Wpisujemy nazwę oraz zgadzamy się na inicjalizację domyślną układów peryferyjnych. W projekcie wykorzystamy USART w trybie asynchronicznym z prędkością 115200 bps oraz przycisk B1 w trybie przerwania (niebieski przycisk na płytce Nucleo) podłączony do pinu PC13. Jeżeli wykorzystałeś domyślna konfigurację płytki przy tworzeniu projektu, jedyne co teraz musisz zmienić to włączenie przerwań EXTI na kanale 13 w kontrolerze NVIC.

Możemy wygenerować projekt za pomocą skrótu Alt+K. Dodajemy pliki json_generator.c oraz json_generator.h odpowiednio do folderów Core->Src oraz Core->Inc. W pliku main.c załączamy bibliotekę.

#include "json_generator.h"

Dodajemy również obsługę przerwania od przycisku:

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
	if (GPIO_Pin == B1_Pin)
	{
		B1_is_clicked = true;
	}
}

Przed funkcją main() warto umieścić makro definiujące długość bufora oraz zmienną przechowującą informację o wciśnięciu przycisku.

#define JSON_BUFFER_SIZE 256

bool B1_is_clicked = false;

Teraz przyjrzymy się, w jaki sposób generować JSON-a za pomocą biblioteki. Oto przykład, który stworzy podobne polecenie jak na początku artykułu.

{
    "event":"button_clicked",
    "number":1
}

Najpierw w funkcji main(), ale przed pętlą while, tworzymy bufor danych oraz strukturę generatora.

char json_buffer[JSON_BUFFER_SIZE];
json_generator_t generator;

Następnie w funkcji main w momencie, gdy zmienna poinformuje nas, że wciśnięto przycisk, inicjalizujemy strunktrę i dodajemy poszczególne elementy JSON-a za pomocą (mam nadzieję) w miarę przejrzystych funkcji.

if (B1_is_clicked == true)
{
    B1_is_clicked = false;

    json_init(&generator, json_buffer, JSON_BUFFER_SIZE);

    json_open_object(&generator);
    json_add_string(&generator, "event", "button_clicked");
    json_add_integer(&generator, "number", 1);
    json_close_object(&generator);

    json_end(&generator);

Przed wysłaniem znaków warto dodać na końcu bufora znaki końca linii, dzięki czemu w terminalu poszczególne polecenia będą pojawiały się w nowych wierszach.

strcat(json_buffer, "\r\n");

Dane przez UART wysyłamy za pomocą polecenia:

HAL_UART_Transmit(&huart2, (uint8_t *)json_buffer, strlen(json_buffer), 100);

Możemy skompilować program i wgrać go na płytkę. W efekcie po każdym wciśnięciu przycisku w terminalu pojawi nam się JSON. W takiej formie możemy go łatwo parsować i analizować za pomocą powszechnych bibliotek JSON dostępnych dla języków wysokiego poziomu. Poniżej wynik działania zarejestrowany za pomocą RealTerm.

Oczywiście biblioteka jest uniwersalna i niezależna od konkretnego interfejsu, dlatego dane możemy równie dobrze przesyłać przez I2C, SPI czy bezprzewodowo przez WiFi lub BLE.

Podsumowanie

W taki sposób możemy generować mniej lub bardziej złożone polecenia w formacie JSON. Napisana biblioteka daje nam prosty i przejrzysty interfejs użytkownika i w miarę sprawnie wypełnia bufor danymi. Dodatkowo zabezpiecza nas przed pewnymi błędami, które mogą się przydarzyć przy budowaniu bardziej zagnieżdżonych poleceń.

Na dzisiaj to wszystko. W kolejnym wpisie będę chciał pokazać, w jaki sposób analizować JSON-y wysłane np. z komputera. Jeżeli podobał Ci się wpis lub masz jakieś własne przemyślenia, zostaw komentarz. Pamiętaj też, aby polubić mój profil na Facebook-u oraz zasubskrybować kanał na YouTube.

Repozytorium GitHub

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *