ESP8266 FAN Controller - opis programu

Opis programu.

 

Po uruchomieniu  Arduno uruchamia się pusty przykładowy szkic:

void setup() {
  // put your setup code here, to run once:

}

void loop() {
  // put your main code here, to run repeatedly:

}

Składa się on z dwóch funkcji:  setup() loop()

Pierwsza z nich uruchamiana jest raz - wpisujemy tutaj dane konfiguracyjne inicjalizację zmiennych itd rzeczy, które wykonają się tylko 1 raz podczas uruchomienia systemu.

Funkcja loop() wykonywana jest cyklicznie z maksymalną możliwą częstotliwością. Jeżeli program opuści pętlę loop() automatycznie powróci do niej ponownie i tak bez przerwy.

Dobrym zwyczajem programistycznym jest aby w funkcji loop było jak najmniej elementów blokujących np. delay(500000); Szczególnie wymagane to jest przy bibliotekach serwera, które użyjemy.

Przykład mruganie diodą led. co 0,5 sekundy z blokowaniem kodu:

void loop() {
  digitalWrite(D0, 1);
  delay(500);// czekamy ... czekamy
  digitalWrite(D0, 0); 
  delay(500); 
}

 Można zamienić na wersję bez blokowania:

unsigned long timeTick = 0;
bool lastLedState = false;
void loop() {
  if (millis() - timeTick >= 500)
  { lastLedState = !lastLedState;
    digitalWrite(D0, lastLedState);
    timeTick = millis();
  }
}

Kod jest bardziej skomplikowany ale w międzyczasie można wykonywać inne rzeczy.

Wracamy jednak do tematu opracowania.

Aby uruchomić nasz serwer potrzebujemy dołączyć dodatkowe biblioteki do naszego programu. Są to zestawy gotowych funkcji i procedur do obsługi odpowiednich urządzeń, a my nie musimy się martwić skomplikowanym kodem.

Bez wnikania na samym początku szkicu wpisujemy:

#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <EEPROM.h>

 Część bibliotek jest standardowo dostępnych w środowisku Arduino część zaś dodaliśmy sami w poprzednim artykule. Patrz: ESP8266 FAN Controller

Pierwszą czynnością jest uruchomienie WiFi:

char ssid[33] = "MY_WIFI_NETWORK";
char PASS[33] = "pass";

void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, PASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi..");
  }
  Serial.print("Connected to: "); Serial.println(ssid);
  Serial.print("My IP is: ");  Serial.println(WiFi.localIP());
}

 oczywiście jest to najprostszy przykład nazwę sieci i hasło wpisujemy w  zmiennych ssid i PASS. Rozwiązanie jest najprostsze jednak ma kilka wad

  • nie obsługuje statycznego adresu IP
  • brak rozwiązania w przypadku braku możliwości połączenia z siecią.

Aby dodać obsługę statycznych adresów IP musimy je najpierw zdefiniować :

const char* FAN_AP_NAME ="FAN_CONTROLLER";
char ssid[33] = "MY_WIFI_NETWORK";
char PASS[33] = "pass";
bool DHCP=true;
IPAddress IP(192, 168, 1, 190);  //domyślne adresy IP
IPAddress  GATE(192, 168, 1, 1);
IPAddress  MASK(255, 255, 255, 0);
IPAddress  DNS1(194, 204, 152, 34);
IPAddress  DNS2(194, 204, 159, 1);

bool isInAP_Mode = false;  // Czy WiFi jest w trybie Access Point'a 
unsigned long apStartTime; // Kiedy Access Point wystartował ???



 Następnie w kodzie dodamy możliwość konfiguracji adresów IP - statyczną jeżeli DHCP=false lub dynamiczną gdy DHCP=true

void setup() {
  Serial.begin(115200);
  WiFi.disconnect();
  WiFi.hostname("NAZWA_HOSTA");

  if (!DHCP)                  // CZY STATYCZNY ADRES IP CZY DYNAMICZNY ???
    WiFi.config(IP, GATE, MASK, DNS1, DNS2);

  WiFi.begin(ssid, PASS);       //INICJALIZACJA WIFI

 Teraz gdy WiFi jest zainicjowane  będziemy sprawdzać co sekundę czy jest połączenie z WiFi. Po 100 sprawdzeniach jeżeli nadal nie ma połączenia uruchomimy WiFi w trybie Access Pointa - umożliwi to konfigurację np. za pomocą telefonu opisaną w poprzednim artykule. Patrz: ESP8266 FAN Controller

 // próba połączenia z WiFi  spróbuj max 100 razy co sekundę)
  int i = 0;
  while (WiFi.status() != WL_CONNECTED && i < 100) {
    delay(1000);
    Serial.println("Connecting to WiFi..");
    i++;
  }

  // Czy Połączono ???
  if (WiFi.status() == WL_CONNECTED)  //TAK
  {
    Serial.print("Connected to: "); Serial.println(ssid);
    Serial.print("My IP is: ");  Serial.println(WiFi.localIP());
  }
  else    //NIE
  {
    Serial.println("Not connected to WiFi - Acces Point Mode");
    WiFi.disconnect();
    WiFi.softAP("NAZWA_SIECI_SSID");
    Serial.print("My IP is: 192.168.4.1");   //Acces Point ma zawsze ten adres IP niezaleznie od konfiguracji
    isInAP_Mode = true;
    apStartTime = millis();
  }

Powyższy kod cały czas jest w pętli setup - czyli wykona się tylko raz.   Jeżeli WiFi nie połączy się z siecią urządzenie wystartuje w trybie Access Pointa. Dobrze byłoby jednak gdyby ten tryb nie trwał wiecznie w przypadkach gdy wejście w w ten tryb nastąpi nieoczekiwanie np po zanikach napięcia.

Kod resetu wpiszemy już w funkcji loop(). Dodatkowo ustawimy aby w przypadku utraty połączenia z WiFi sterownik próbował połączyć się ponownie a po wielu nieudanych próbach też się zrestartował.

...
unsigned long tenSec = 0;   // zmienna do pętli 10 sekund
int recon = 0;              // liczba powtórzeń gdy brak połaczenia z WiFi

...
void REBOOT()
{ delay(1000);
  ESP.reset();
  delay(1000);
}

...
void loop() {
  if (millis() - tenSec > 10000)              // sprawdzaj co 10 sekund
  {
    if (WiFi.status() != WL_CONNECTED)        // jeżeli brak połączenia lub połączenie zostało utracone
    {
      recon++;
      if ( recon > 100) REBOOT();             // uruchom ponownie po 50 próbach (1000 sekund)
      if (recon % 2 == 0) WiFi.reconnect();   // próbuj połączyć co 20 sekund
    }
    else recon = 0;

    if (isInAP_Mode && millis() - apStartTime >  600000) REBOOT();  // jeżeli Access Point Mode restart po 10 minutach
    tenSec = millis();
  }
}

 Tak  pokrótce wygląda uruchomienie WiFi. Teraz należy uruchomić serwer. Na początku dodajemy zmienną

AsyncWebServer server(80);

 Serwer już działa. Jednak nie wie jak odpowiadać na nasze zapytania. dlatego dalej w konfiguracji (pętla setup) dodajemy obsługę zdarzeń. Dodaje się ją wywołując funkcję server.on. ma ona kilka  implementacji.

Zaczniemy od głównej strony index.html.  żeby było uniwersalnie powinna się ładować po zapytaniu /, /index.htm, /index.html lub /index

  server.on("/", HTTP_GET, &indexHTML);
  server.on("/index.html", HTTP_GET, &indexHTML);
  server.on("/index.htm", HTTP_GET, &indexHTML);
  server.on("/index", HTTP_GET, &indexHTML);

 musimy dodać funkcję obsługi w kodzie. musi się ona znajdowac powyżej funkcji setup

void indexHTML(AsyncWebServerRequest * request)
{
  request->send(200, "text/plain", "OK");
}

Na  zapytanie serwera zwróci on tekst OK. Jednak strona z OK nie jest zbyt atrakcyjna. Można przesłać dłuższy tekst z kodem HTML jednak na dłuższą metę dla bardziej skomplikowanych stron może to być problem.

Płytka ESP 8266 w uproszczeniu może mieć pamięć podzieloną na dwie części  pierwsza na pamięć programu  druga na pliki.  Do drugiej możemy mieć dostęp z programu jak do karty SD. Jest to bardzo wygodne rozwiązanie. Zapiszemy tam wszystkie pliki naszego serwera, a w programie będziemy je wysyłać bezpośrednio nie zabierając pamięci programu na kod stron, obrazki, skrypty i inne elementy.

Aby to było możliwe wpiszemy najpierw inicjalizację SPIFFS:

 
  if (!SPIFFS.begin()) {
    Serial.println("Failed to mount FS");
    return;
  }

 Teraz serwer może wysłać dowolny plik poprzez SPIFFS

 
  server.on("/fan_off.gif", HTTP_GET, [](AsyncWebServerRequest * request) {
    request->send(SPIFFS, "/fan_off.gif", "image/gif");
  });

na koniec definicji zdarzeń serwera (server.on) definiujemy stronę onNotFound(nie znaleziono) i uruchamiamy serwer:

 
  server.onNotFound(indexHTML);
  server.begin();

Najprostszym rozwiązaniem na stronę nie znaleziono - jest przekierowanie na stronę główną co zrobiłem.

Serwer już powinien działać. Jednak nie przesyła on na razie żadnych danych  poza statycznymi stronami.

Potrzebujemy przesłać stan zmiennych np. w naszym przypadku stan wentylatorów, czy ustawienia konfiguracyjne.

Dobrze aby strona załadowała się już z tymi danymi np ustawienia wentylatorów a później w razie potrzeby aktualizowała je cyklicznie.

Jeżeli w kodzie  html wpiszemy zmienne w postaci %VAR1%  %VAR2%  itd gdzie VAR1 to nazwa zmiennej np FAN_VALUE, to serwer może je przetworzyć  i zastąpić wymaganą wartością.

Do przetworzenia i dodania zmiennych służy nam funkcja processor.

Najpierw dodajemy funkcję procesora przed funkcją setup()

String processor(const String& var) {
  if (var == "FAN_VALUE") return String(fanValue);
  return var;
}

 teraz w funkcji server.on  dodajemy obsługę procesora

void indexHTML(AsyncWebServerRequest * request)
{
  request->send(SPIFFS, "/index.html",  String(), false, processor);
}

Serwer wyśle już przetworzoną stronę html.

Jednak potrzebujemy aby strona pobierała i wysyłała dane o stanie wentylatorów na bieżąco. Wymaga to napisania funkcji po stronie serwera jak i przeglądarki.

Przeglądarka internetowa z zaladowaną stroną musi wysłać zapytanie na serwer. Zastosowałem tutaj metodę HTTP_GET. Jest ona najprostsza i łatwo też za jej pomocą zintegrować nasze urządzenie z innym oprogramowaniem.

Utworzyłem dwie strony get_fans.html i set_fans.html

Strona get_fans.html zwraca stan wentylatorów:

 
void get_fans_html()
{
  server.on("/get_fans.html", HTTP_GET, [](AsyncWebServerRequest * request) {
    request->send(SPIFFS, "/get_fans.html", String(), false, processor2);
  });
}

Gdzie processor2 - odpowiedzialny jest za obsługę zmiennych i ich zaktualizowanie.

Po stronie przeglądarki wysyłane jest cyklicznie proste zapytaniew javascript:

function Receive() {
  req = new XMLHttpRequest();
  var d = new Date();
  var n = d.getTime();
  req.open("GET", "get_fans.html?TS=" + n + "", true);
  req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
  req.onload = onLoad;
  req.onerror = onError;
  req.send(null);
}


function onError(e) {
  ...
}

function onLoad(data) {
  ..
  try {
    var lines = data.target.response;
    ...
  }
  catch (e) { }
  ...
}

Strona html wysyła zapytanie na serwer o pobranie strony get_fans.html?TS=.... gdzie po ts dodawany jest znacznik czasu. Omija to ewentualne cache przeglądarki. Żądanie pobrania strony za każdym razem jest inne i strona jest ładowana z serwera(sterownika). Po odebraniu dane są dalej aktualizowane w skrypcie. Nie będę ty szczegółowo tego analizował - każda realizacja ma inne zmienne więc i inną obsługę. Po szczegóły zapraszam do źródeł programu.

Obsługa strony  set_Fans jest bardziej skomplikowana. Za jej pomocą ustawiamy wartości elementów. Tu także wykorzystywana jest metoda HTTP_GET. Kod javaScript:

function sendIO(command) {
  req2 = new XMLHttpRequest();
  var d = new Date();
  var n = d.getTime();
  req2.open("GET", "set_fans.html?TS=" + n + "&" + command, true);
  req2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
  req2.send(null);
}

HTTP_GET umożliwia wysłanie danych bezpośrednio w linku po znaku zapytania np:

set_fans.html?TS=10239218919&e1=1&e2=1&e3=1&e4=1&v1=111&v2=222&v3=345&v4=1023

 Gdzie TS- podobnie jak poprzednio jest znacznikiem czasu a kolejno e1 do e4 określają włączenie lub wyłączenie odpowiedniego wentylatora (1 włącz 0 wyłącz) a v1 do v4 - prędkość obrotową 0 to 0% a 1023 to 100%

Takie rozwiązanie pozwala w łatwy sposób sterować urządzeniem z dowolnej innej aplikacji, własnej strony internetowej czy umożliwia prostą integrację z resztą automatyki domowej.

Obsługa set_fans.html jest trudniejsza po stronie sterownika. Musimy pobrać argumenty z zapytania(query)  wykorzystujemy tutaj funkcję getParam. Może to wyglądać tak:

bool fanEnabled[4] = {1, 1, 1, 1};        
int fanValues[4] = {511, 511, 511, 511};  
...
void set_fans_html()
{
  server.on("/set_fans.html", HTTP_GET, [](AsyncWebServerRequest * request) {
    bool isDirty = false;
    String inputMessage = "";
    for (byte i = 0; i < 4; i++)
    {
      String e1 = "e";
      String e = e1 + String(i);
      if (request->hasParam(e)) {
        inputMessage = request->getParam(e)->value();
        byte old = fanEnabled[i];
        if (inputMessage == "1") fanEnabled[i] = 1; else fanEnabled[i] = 0;
        if (old != fanEnabled[i]) isDirty = true;
      }
    }
    for (byte i = 0; i < 4; i++)
    {
      String v1 = "v";
      String v = v1 + String(i);
      if (request->hasParam(v)) {
        inputMessage = request->getParam(v)->value();
        int val = inputMessage.toInt();
        if(val<0) val=0;
        if (val>1023) val=1023;
        byte old = fanValues[i];
        fanValues[i] = val;
        if (old != fanValues[i]) isDirty = true;
      }
    }
    if (isDirty)
    {
      writeFanValues();  // zapisz do EEPROM
      procedIO();        // ustaw wartości PWM
    }
    request->send(200, "text/html", "OK");
  });
}

 Po krótce serwer działa dane wentylatorów się zapisują i przesyłają. Pozostaje jeszcze główna część - konfiguracja jej zapis odczyt i przesyłanie na stronę.

Wykorzystamy tutaj metodę HTTP_POST służy ona do przesyłania dużo większej ilości danych, które nie są widoczne bezpośrednio w linku do strony. Przesyłane są w tle.

Aby przesłać dużo zmiennych po stronie klienta sformatujemy je w plik JSON. Jest to standard przesyłania danych. Przykładowy plik JSON wygląda tak:

{
  SSID: "MOJA_SIEC_WIFI",
  PASS1: "pass",
  PASS2: "pass",
  isDHCP: "false",
  IP: "192.168.0.190",
  GATE: "192168.0.1",
  MASK: "255.255.255.0",
  DNS1: "194.204.152.34",
  DNS2: "194.204.159.1",
  securityLevel: "0",
  userName: "admin",
  userPass1: "admin",
  userPass2: "admin"
}

 Dane formatujemy po stronie przeglądarki internetowej. Z utworznego w kodzie html formularza pobieramy dane.  Należy zadbać o sprawdzenie poprawności danych zakresu czy nie występują nieodpowiednie znaki np " . następnie dane wysyłamy metodą post:

req2 = new XMLHttpRequest();

req2.open("POST", "syscfg.html", true);
req2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
req2.onload = function () {
...
}
req2.onerror = function () {
...
}
req2.send(dane);

 Po stronie sterownika odczytujemy otrzymane dane i zapisujemy na naszą "kartę SD" czyli SPIFFS:

void config_html()
{
  // wyślij stronę na żądanie GET
  server.on("/syscfg.html", HTTP_GET, [](AsyncWebServerRequest * request) {
    if (!auth1(request)) return;
    request->send(SPIFFS, "/syscfg.html",   String(), false, processor2);
  });
  
  //Odbierz dane POST
  server.on("/syscfg.html", HTTP_POST, [](AsyncWebServerRequest * request) {
    int params = request->params();
    // for (int i = 0; i < params; i++) {
    if (params > 0) {
      AsyncWebParameter* p = request->getParam(0);
      if (p->isPost()) {

        // zapis
        File configFile = SPIFFS.open("/syscfg.json", "w");
        if (configFile){
          request->send(200, "text/plain", "OK");
          configFile.print(p->value());
          configFile.close();
          return;
        }
      }
    }
    request->send(405, "text/plain", "ERR");
  });
}

Dane są konfiguracyjne już się zapisują w naszym sterowniku, Ale podczas uruchomienia sterownika powinny zostać odczytane, skonwertowane i przypisane do zmiennych.

Aby je odczytać podczas uruchomienia sterownika wywołujemy funkcję :

bool  readSystemConfig()
{
  bool jsonOK = false;
  if (SPIFFS.exists("/syscfg.json")) {

    File configFile = SPIFFS.open("/syscfg.json", "r");
    if (configFile) {
      size_t size = configFile.size();
      std::unique_ptr<char[]> buf(new char[size]);
      configFile.readBytes(buf.get(), size);
      DynamicJsonBuffer jsonBuffer;
      JsonObject& json = jsonBuffer.parseObject(buf.get());
      configFile.close();

      if (json.success()) {
        jsonOK = true;
        if (json.containsKey("SSID")) strcpy(ssid, json["SSID"]); else  jsonOK = false;
        if (json.containsKey("PASS1")) strcpy(PASS, json["PASS1"]); else jsonOK = false;
        ....
      }
    }
  }
  return jsonOK;
}

Oczywiście kod całego programu zawiera dodatkowe elementy. Tutaj opisałem jedynie jego newralgiczne fragmenty.  Całość do pobrania jest stąd:

 

Projekt szkicu Arduno: FAN_Controller.zip  ~60kB

Projekt płytki i schemat: schematyPDF.zip ~1,4MB

 

Script logo