NodeMCU – Zapis konfiguracji w pamięci flash

W poprzednim artykule opisałem w jaki sposób można przy pomocy NodeMCU rozgłaszać własną sieć WiFi. Dzisiaj zrobimy kolejny krok ku odbieraniu i przechowywaniu konfiguracji WiFi w pamięci procesora – dowiemy się jak wykorzystać system plików SPIFFS do zapamiętania danych konfiguracyjnych urządzenia.

Czym jest SPIFFS? Skrót ten rozwija się jako SPI Flash File System i jest niczym innym jak prostym systemem plików dostępnym w modułach ESP8266. Chcąc zacząć z niego korzystać musimy załączyć do programu bibliotekę FS.h.

#include "FS.h"

Podobnie jak przy użyciu portu szeregowego tutaj też musimy zainicjować połączenie za pomocą metody begin().

SPIFFS.begin();

Teraz możemy śmiało korzystać z funkcji jakie oferuje nam ta biblioteka. Aby otworzyć plik wywołujemy metodę open(scieżka, tryb), która zwróci nam zmienną typu File.

File plik = SPIFFS.open("/plik.txt", "w");
if(!plik) {
    // Nie udało się otworzyć pliku
}

Poza tą najważniejszą metodą mamy do dyspozycji jeszcze kilka innych takich jak np. exists, remove, rename itp. Listę tych metod oraz parametrów, które przyjmują możemy znaleźć w dokumentacji.

Załóżmy, że udało się pomyślnie otworzyć plik. Obiekt typu File obsługuje wszystkie metody klasy Stream, więc możemy odczytywać jego zawartość np. za pomocą metody read() lub readBytes().

size_t rozmiar = plik.size(); // Określamy rozmiar pliku
char *bufor = new char[rozmiar]; // Alokujemy tablicę charów, do której odczytamy plik
plik.readBytes(bufor, rozmiar); // Odczytujemy zawartość pliku

delete[] bufor; // Zwalniamy zaalokowaną pamięć po skończeniu pracy z zawartością pliku

Należy pamiętać, aby po skończonej pracy z plikiem zamknąć go:

plik.close();

Implementacja zapisu i odczytu konfiguracji

Konfigurację na urządzeniu wartoby trzymać w jakimś konkretnym, ogólnie stosowanym standardzie. Ja wybrałem JSONa do którego mogę polecić bardzo fajną bibliotekę o nazwie ArduinoJson. Można ją pobrać poprzez wbudowanego w Arduino IDE menedżera bibliotek.

Z racji tego, że cały projekt może wyjść dość spory jak na jeden plik to już teraz zaczniemy dzielić go na odpowiednie klasy. Tę nad którą obecnie pracujemy nazwałem ConfigManager. Taka klasa w języku C++ składa się z dwóch plików – nagłówkowego (.h) oraz ciała (.cpp). W pierwszym z nich zawieramy tylko informacje o strukturze klasy, natomiast w drugim implementujemy jej funkcjonalności.

Na potrzeby artykułu pokażę w jaki sposób zaimplementować odczyt i zapis nazwy sieci WiFi z którą będzie łączyło się ESP oraz hasła do niej. Na początek stwórzmy plik ConfigManager.h o następującej zawartości:

#ifndef CONFIGMANAGER_H
#define CONFIGMANAGER_H


class ConfigManager {
  private:
    char *ssid;
    char *pass;
    ConfigManager();
  public:
    ~ConfigManager();
    static ConfigManager& getInstance();
    bool load();
    bool save();
    bool reset();
    bool isDeviceConfigured();
    void getSSID(char *buf);
    void setSSID(const char *ssid);
    void getPassword(char *buf);
    void setPassword(const char *password);
};

#endif /* CONFIGMANAGER_H */

Poza samą definicją klasy mamy tutaj też tak zwane „include guards”. Są to te linijki #ifdef, #define oraz #endif. Pełnią one rolę strażników, którzy pilnują żeby nikt nie załączył tego pliku nagłówkowego więcej niż raz.

Dosłownie można to interpretować w ten sposób: jeżeli nie jest zdefiniowane CONFIGMANAGER_H to zdefiniuj CONFIGMANAGER_H, zdefiniuj klasę, zakończ warunek.

Kolejną rzeczą, która może wzbudzać niepokój wśród niektórych czytelników jest prywatny konstruktor. Po co robić klasę, skoro nikt nie będzie mógł stworzyć jej obiektu? Otóż w przypadku menedżera konfiguracji chcielibyśmy w całym programie mieć tylko jedną jego instancję (bo po co nam kilka obiektów działających na tych samych danych?) w celu zaoszczędzenia pamięci. Właśnie po to użyliśmy tu prywatnego konstruktora oraz publicznej statycznej metody getInstance(), która zwróci nam instancję naszej klasy i zadba o to, żeby w pamięci istniałą tylko jedna taka instancja. W ten sposób zaimplementujemy wzorzec projektowy o nazwie Singleton.

Myślę, że reszta metod nie powinna sprawaić problemu co do zrozumienia ich funkcji po ich nazwie, także przejdziemy teraz do implementacji. W tym celu tworzymy plik ConfigManager.cpp.

Na początku tego pliku załączamy odpowiednie pliki nagłówkowe, z których będziemy korzystać:

#include "ConfigManager.h"
#include <ArduinoJson.h>
#include "FS.h"

Teraz możemy zaimplementować konstruktor, dekonstruktor oraz metodę getInstance():

ConfigManager::ConfigManager() {
  SPIFFS.begin();
  this->ssid = new char[64];
  this->pass = new char[64];
}

ConfigManager::~ConfigManager() {
  delete[] this->ssid;
  delete[] this->pass;
}

ConfigManager& ConfigManager::getInstance() {
  static ConfigManager instance;
  return instance;
}

W konstruktorze poza inizjalizacją systemu plików alokujemy miejsce dla stringów ssid oraz pass. 64 znaki powinny wystarczyć. Oczywiście w dekonstruktorze robimy dokładnie odwrotnie ;)

Jak widać po metodzie getInstance() zaimplementować singleton można w dosłownie 2 linijkach :) Wystarczy zwrócić statyczną zmienną przechowującą instancję klasy.

Ok, przejdźmy teraz do kolejnych równie ważnych metod, a mianowicie load() oraz save(). Mają one za zadanie wczytać informacje z pliku do pola w klasie oraz zapisać dane z pola do pliku. Na początek weźmy na warsztate metodę load():

bool ConfigManager::load() {
  File configFile = SPIFFS.open("/config.json", "r");
  if (!configFile) {
    return false;
  }
  
  size_t size = configFile.size();

  char *buf = new char[size];
  
  configFile.readBytes(buf, size);
  
  StaticJsonBuffer<200> jsonBuffer;
  JsonObject& json = jsonBuffer.parseObject(buf);
  
  if (!json.success()) {
    delete buf;
    return false;
  }

  const char* ssid = json["ssid"];
  strcpy(this->ssid, ssid);
  const char* pass = json["pass"];
  strcpy(this->pass, pass);
  
  delete buf;
  configFile.close();
  return true;
}

Sprowadza się ona do otworzenia pliku, sprawdzenia jego rozmiaru, zaalokowanie odpowiedniej ilości pamięci w celu odczytania jego zawartości po czym przy pomocy biblioteki ArduinoJson parsujemy odczytane dane do obiektu typu JsonObject. Jeżeli wszystko pójdzie zgodnie z planem to odczytujemy stringi ssid oraz pass z naszego głównego obiektu json, a następnie kopiujemy je do dynamicznie zaalokowanych pól o takich samych nazwach. Należy tutaj bezwzględnie pamiętać o tym, żeby zwolnić pamięć zaalokowaną przez bufor buf oraz zamknąć plik. Jeżeli tego nie zrobimy to może okazać się, że po kilkunastu / kilkudziesięciu / kilkuset (niewłaściwe skreślić) odczytach skończy nam się pamięć RAM, której nie mamy dużo.

Zajrzyjmy teraz pod maskę meody save(), której implementacja jest dużo łatwiejsza:

bool ConfigManager::save() {
  StaticJsonBuffer<200> jsonBuffer;
  JsonObject& json = jsonBuffer.createObject();
  json["ssid"] = this->ssid;
  json["pass"] = this->pass;
  
  File configFile = SPIFFS.open("/config.json", "w");
  if (!configFile) {
    return false;
  }
  json.printTo(configFile);
  configFile.close();
  return true;
  
}

Tutaj cała filozofia sprowadza się do otworzenia pliku, utworzenia obiektu JsonObject, wpisania do niego odpowiednich wartości, a następnie zapisaniu tak przygotowanego obiektu do pliku.

Cała reszta implemntacji klasy sprowadza się w praktyce do kopiowania stirngów między buforami lub wywoływania już zaimplementowanych przez nas metod.

bool ConfigManager::reset() {
  setSSID("");
  setPassword("");
  return save();
}

bool ConfigManager::isDeviceConfigured() {
  return this->ssid[0] != '\0';
}

void ConfigManager::getSSID(char *buf) {
  strcpy(buf, this->ssid);
}

void ConfigManager::setSSID(const char *ssid) {
  strcpy(this->ssid, ssid);
}

void ConfigManager::getPassword(char *buf) {
  strcpy(buf, this->pass);
}

void ConfigManager::setPassword(const char *password) {
  strcpy(this->pass, password);
}

Wyjątek może tu stanowić metoda isDeviceConfigured(), która stwierdza czy urządzenie jest skonfigurowane po tym, że ciąg ssid nie jest pusty. Wszak jeśli nasze urządzenie, którego głównym celem jest połączyć się z WiFi nie wie do jakiego WiFi ma się połączyć to siłą rzeczy nie może być skonfigurowane.

Jak korzystać z naszej klasy?

Oto przykładowy program zapisujący i odczytujący konfigurację:

#include "ConfigManager.h"

ConfigManager configManager = ConfigManager::getInstance();

void setup() {
  Serial.begin(115200);
  Serial.println();
  Serial.println("Booted");
  Serial.println("------");

  if(!configManager.load()) {
    Serial.println("Nie udalo sie wczytac ustawien");
  }

  if(!configManager.isDeviceConfigured()) {
    configManager.setSSID("Nazwa_sieci");
    configManager.setPassword("12345678");
    
    if(!configManager.save()) {
      Serial.println("Nie udalo sie zapisac ustawien");
    } else {
      Serial.println("Zapisano nowe ustawienia");
    }
  }

  char ssid[64], pass[64];
  configManager.getSSID(ssid);
  configManager.getPassword(pass);

  Serial.print("SSID: ");
  Serial.println(ssid);
  
  Serial.print("Haslo: ");
  Serial.println(pass);
}

void loop() {
}

Po jego skompilowaniu uruchamiamy port szeregowy. Powinniśmy ujrzeć coś takiego:

Jak widać tylko po pierwszym uruchomieniu zostały zapisane nowe ustawienia. Przy kolejnych resetach ustawienia te są już tylko i wyłącznie wczytywane z pamięci.

Połączenie ConfigManagera z kodem z pierwszego artykułu

Jeżeli nasz program realizowany w tym mini-kursie ma spełniać początkowe założenia, czyli rozgłaszać sieć WiFi, odbierać ustawienia i zapisywać je w pamięci musimy powyższy szkic połączyć ze szkicem, który napisaliśmy w poprzednim artykule. Zasada działania takiej fuzji powinna być następująca:

Wczytujemy konfigurację, jeżeli urządzenie nie jest skonfigurowane to uruchamiamy naszą sieć WiFi, a następnie czekamy na nadejście danych konfiguracyjnych. Jeżeli jednak urządzenie jest skonfigurowane (tj. ma wpisane przynajmniej SSID) to pobieramy te dane do zmiennych, a następnie łączymy się z wybraną siecią. Realizacja takiego algorytmu wygląda następująco:

#include "ConfigManager.h"
#include <ESP8266WiFi.h>

ConfigManager configManager = ConfigManager::getInstance();

void setup() {
  Serial.begin(115200);
  Serial.println();
  Serial.println("Booted");
  Serial.println("------");

  if(!configManager.load()) {
    Serial.println("Nie udalo sie wczytac ustawien");
  }

  if(!configManager.isDeviceConfigured()) {
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    delay(100);
    IPAddress localIp(192,168,1,1);
    IPAddress gateway(192,168,1,1);
    IPAddress subnet(255,255,255,0);
    WiFi.softAPConfig(localIp, gateway, subnet);
    WiFi.softAP("Nettigo Config");

    while(true) {
      /**
       * Tutaj w przyszłości uruchomimy nasłuch UDP lub HTTP.
       * Zadba on o pobranie i zapisanie ustawień, a także restart urządzenia.
       */
    }
  }

  char ssid[64], pass[64];
  configManager.getSSID(ssid);
  configManager.getPassword(pass);

  WiFi.begin(ssid, pass);
  Serial.print("Laczenie z ");
  Serial.print(ssid);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.println("Polaczono");
}

void loop() {
}

Jak widać zostawiłem tutaj pustą pętlę while(true) z komentarzem. W kolejnych artykułach dowiemy się co tam wpisać, żeby było dobrze :) Na dziś to już wszystko, mam nadzieję że udało mi się wytłumaczyć zasadę działania programy, sens dzielenia na klasy oraz stosowania wzorców projektowych w przystępny sposób. Jeżeli macie jakieś pytania to śmiało pytajcie w komentarzach.