ESP32 com MQTT, servidor web e sistema de arquivos

Eis mais um artigo super elaborado! E trata-se de um projeto para um produto, onde deve-se rodar um ESP32 com MQTT, webserver e, nesse caso, fundamentalmente um sistema de arquivos. Explico.

Placa de desenvolvimento para ESP32

Antes de começar, gostaria de citar que essa placa de desenvolvimento é “slotável”, esse ESP32 está apenas encaixado sobre ela e assim desenvolvo projetos em paralelo, apenas trocando o processador.

Essa placa está disponível no parceiro CurtoCircuito (único lugar que encontrei para venda). Tem outros modelos também, é só dar uma chegada e escolher o seu.

Utilizando o sistema de arquivos SPIFFS

Já escrevi diversos artigos falando sobre o uso do SPIFFS, particionamento da memória flash e exemplos para ESP8266 e ESP32.

Nesse projeto, a ideia inicial era fazer a gravação dos parâmetros de configuração em uma memória EEPROM externa. Oras, tendo um sistema operacional com um sistema de arquivos rodando no ESP32, não dá pra ter dúvidas de qual utilizar, certo?

Para utilizar o sistema de arquivos SPIFFS é muito simples; basta declarar as funções para leitura, escrita, renomeação, adição e exclusão dos arquivos criados. Vou dispor o código para utilização do SPIFFS, primeiramente.

#include <Arduino.h>
#include "SPIFFS.h"

#define MAX_LEN   50

//Variável para guardar o nome de arquivo de configuração.
//Poderia ser um define, mas aí seria imutável. Desse modo,
//dá pra atualizar e manter diversas versões.
char VERSION[15] = {0};

//estrutura do arquivo de configuração.
struct configStruct{
  char ssid[MAX_LEN]         = {0};
  char senha[MAX_LEN]        = {0};
  char senha1[MAX_LEN]       = {0};
  char mqtt_host[MAX_LEN]    = {0};
  char mqtt_port[MAX_LEN]    = {0};
  char clientId[MAX_LEN]     = {0};
  char mqtt_pass[MAX_LEN]    = {0};
  char publish_to[MAX_LEN]   = {0};
  char subscribe_to[MAX_LEN] = {0};
} wifiConfig;

void writeFile(fs::FS &fs, const char * path, const char * message);
void readFile(fs::FS &fs, const char * path);
void deleteFile(fs::FS &fs, const char * path);
void appendFile(fs::FS &fs, const char * path, const char * message);
void saveConfig(const char * path);
void loadConfig(const char * path);
void check_if_ap_file_exists(char *path);

/*Como não foi possível interromper o socket aberto, apenas interrompendo*/
void check_if_ap_file_exists(char *path){
    if (SPIFFS.exists(path)){
      deleteFile(SPIFFS, path);
      preLoad();
      startWifiMode(ESP32_MODE_AP);
      esp32_wifi_mode = ESP32_STATE_AP;
      webserver.begin();
    }
    else{
      vTaskDelay(pdMS_TO_TICKS(5000));
        Serial.println("AP mode file not found. No connections for now.");
    }
}

//configuração inicial se não houver arquivo de versão ou se falhar ao tentar
//abrir o arquivo. Se essa função for chamada para pré-carregar a struct, então
//seria ideal ter o SSID e password para AP e STA.
void preLoad(){
  strcpy(wifiConfig.ssid,"SeuSSID");
  strcpy(wifiConfig.senha,"SuaSenha");
  strcpy(wifiConfig.senha1,"NaoFuiEuQueDefiniEssaVariavel");
  strcpy(wifiConfig.mqtt_host,"192.168.1.10");
  strcpy(wifiConfig.mqtt_port,"1883");
  strcpy(wifiConfig.clientId,"UmNomeUnico");
  strcpy(wifiConfig.mqtt_pass,"senha");
  strcpy(wifiConfig.subscribe_to,"/listen");
  strcpy(wifiConfig.publish_to,"/status");

  wifiConfig.ip      = IPAddress(192,168,1,123);
  wifiConfig.gateway = IPAddress(192,168,1,1);
}

//loadConfig - Ao apertar o botão de carga de configuração, carrega e inicia wifi.
/* O caminho do arquivo no sistema de arquivos sempre deve começar com "/".
O define VERSION guarda esse valor e está logo após os includes.
void loadConfig(fs::FS &fs, const char * path){
  Serial.printf("Reading file: %s\n", path);

  if (path[0] != '/'){
    Serial.println("File path needs start with /. Change it.");
    return;
  }

  //Se o arquivo de versão não existir, pre-carrega essas configurações.
  if (!SPIFFS.exists(path)){
    Serial.println("File does not exists. Preloading...");
    Serial.print("Version: ");
    Serial.println(VERSION);
    preLoad();
    //Salva o arquivo de versão no sistema de arquivos e sai.
    saveConfig(path);
    return;
  }

  //Se o arquivo existe, então essa função vem direto para cá.
  //Se não conseguir abrir o arquivo, sai.
  File file = fs.open(path);
  if(!file || file.isDirectory()){
      Serial.println("Failed to open file for reading");
      //se deu problema, carrega as configurações padrão.
      preLoad();
      return;
  }

  /* Abriu o arquivo, agora vem uma diversão*/
  Serial.println("Read from file: ");
  /* Invés de ler 1 linha e alimentar 1 item, esse ponteiro abaixo pega o
  endereço de memória onde o array foi alocado. Depois, lê-se do arquivo de
  configurações em um loop, procurando por CR. Daí copia-se o array de
  char do arquivo para o endereço de memória correspondente e avança-se para
  o próximo endereço, onde estará o byte 0 do próximo item.
  O valor foi definido no define MAX_LEN e o tipo é char.*/
  char *ptr = &wifiConfig.ssid[0];

  while(file.available()){
    //le uma linha do arquivo.
    file.readBytesUntil('\n', line, MAX_LEN);
    Serial.print("var: ");
    //Serial.println(line);
    //copia a linha para o endereço do ponteiro
    strcpy(ptr,line);
    //imprime o valor guardado no endereço correspondente.
    Serial.println(ptr);
    ////Serial.println(ptr);
    //avança pro próximo endereço
    ptr = ptr+MAX_LEN;
    //limpa a variável que guarda o valor lido do arquivo
    memset(line,0,MAX_LEN);
  }
  file.close();
  /*Modos: MODE_STA ou MODE_AP. Esse modo pode vir da carga das configurações.*/
  startWifiMode(ESP32_MODE_STA);
}

/* Chamado dentro da função de carga*/
void writeFile(fs::FS &fs, const char * path, const char * message){
    Serial.printf("Writing file: %s\n", path);

    /* cria uma variável "file" do tipo "File", então chama a função
    open do parâmetro fs recebido. Para abrir, a função open recebe
    os parâmetros "path" e o modo em que o arquivo deve ser aberto
    (nesse caso, em modo de escrita com FILE_WRITE).
    O nome de arquivo deve começar com /
    */
    if (path[0] != '/'){
      Serial.println("Path needs start with / before filename. File not created.");
      return;
    }

    if (SPIFFS.exists(path)){
      Serial.println("File exists. Delete before write it again. Exiting.");
      return;
    }

    File file = fs.open(path, FILE_WRITE);
    //verifica se foi possivel criar o arquivo
    if(!file){
        Serial.println("Failed to open file for writing");
        return;
    }
    /*grava o parâmetro "message" no arquivo. Como a função print
    tem um retorno, ela foi executada dentro de uma condicional para
    saber se houve erro durante o processo.*/
    if(file.print(message)){
        Serial.println("File written");
    } else {
        Serial.println("Write failed");
    }
    file.close();
}

//chamado na função de carga.
void readFile(fs::FS &fs, const char * path){
    Serial.printf("Reading file: %s\n", path);

    if (path[0] != '/'){
      Serial.println("File path needs start with /. Change it.");
    }

    File file = fs.open(path);
    if(!file || file.isDirectory()){
        Serial.println("Failed to open file for reading");
        return;
    }

    Serial.println("Read from file: ");
    while(file.available()){
        Serial.write(file.read());
    }
    file.close();
}

//Pode-se ter diferentes versões do arquivo de configuração guardadas no
//sistema de arquivos. Para escolher qual acessar, basta mudar o nome do
//arquivo no define VERSION. Quando quiser excluir, basta chamar essa função
//passando o nome do arquivo.
void deleteFile(fs::FS &fs, const char * path){
    if (path[0] != '/'){
      Serial.println("File path needs start with /. Change it.");
    }
    Serial.printf("Deleting file: %s\n", path);
    if(fs.remove(path)){
        Serial.println("File deleted");
        return;
    }
    Serial.println("Delete failed");
}

//chamada na função que grava o arquivo.
void appendFile(fs::FS &fs, const char * path, const char * message){
    if (path[0] != '/'){
      Serial.println("File path needs start with /. Change it.");
    }

    Serial.printf("Appending to file: %s\n", path);

    File file = fs.open(path, FILE_APPEND);
    if(!file){
        Serial.println("Failed to open file for appending");
        return;
    }

    if(file.print(message)){
        Serial.println("Message appended");
    } else {
        Serial.println("Append failed");
    }
    file.close();
}

void setup(){
    Serial.begin(115200);
    //cria o nome do arquivo de configuração
    strcpy(VERSION, "/version-0.1");

    //Se der erro, já era. Tem que mexer no particionamento.
    if (!SPIFFS.begin(true)){
         Serial.println("Couldn't mount the filesystem.");
    }
    //Explicarei também no artigo.
    check_if_ap_file_exists((char *)START_AP);
}

Já coloquei acima alguns outros recursos que são relacionados. Agora explico.

writeFile(fs::FS &fs, const char * path, const char * message)

O filesystem passado como primeiro parâmetro é o SPIFFS. O segundo parâmetro é o caminho absoluto do arquivo que será gravado. No caso, o arquivo de versão. Por último, passamos o conteúdo para o arquivo.

readFile(fs::FS &fs, const char * path)

Essa função recebe apenas os parâmetros filesystem caminho absoluto. Dentro da função está disposta a leitura. Em todas as funções estão alguns tratamentos de exceção, por exemplo, a verificação da existência do arquivo, o acesso ao arquivo etc. É fundamental tratar as exceções para evitar que aconteçam reinicializações. Quanto mais código, menos trabalho, acredite.

deleteFile(fs::FS &fs, const char * path)

Do mesmo modo, passa-se o filesystemcaminho absoluto. As exceções são tratadas e então o arquivo (se existir) é removido.

appendFile(fs::FS &fs, const char * path, const char * message)

Um arquivo pode ser incrementado, como é o caso dos logs. Resolvi criar um logger para ter parâmetros para uma análise em caso de problemas. Como um arquivo de log pode ser acessado inúmeras vezes, é fundamental que seja possível adicionar dados a ele, mas esse arquivo não deve crescer de forma indefinida, por isso limitei o tamanho a 2048 Bytes. Poderia renomeá-lo ao chegar no tamanho limite e criar então sua sequência, mas pense no que aconteceria se o produto estivesse no cliente e gerando logs continuamente. Em algum momento o sistema de arquivos seria preenchido e então a solução se transformaria em um bug.

check_if_ap_file_exists(char *path)

Essa função já faz uso dos recursos anteriores.

O ESP32 poderá em dado momento trabalhar em modo AP e o modo STA precisa ser desligado. Bem, quando se usa a função WiFi.disconnect(true)supostamente haveria de desconfigurar a rede. O problema é que se estiver iniciado o MQTT, não basta pará-lo para interromper o processo, pois tem também um socket alocado. Desconectar o socket mantém o objeto, por isso seria necessário abrir um novo para uma nova conexão. Para que fique disponível de forma global, esse socket é criado no começo do programa, então, como mudar do modo station para o modo access point? A melhor solução que encontrei foi criar um arquivo .ini e fazer um softrestart. O softrestart é feito com a chamada ESP.restart(). Para trocar entre STA e AP (lembrando que quando rodando em modo AP, não deve executar o MQTT), criei uma função para fazer a configuração.

O último caso é ter iniciada a configuração em modo STA mas estar com o MQTT parado. Nesse caso, não precisa reiniciar.

/* Modo de operação do WiFi (MODE_AP ou MODE_STA).
Existe uma configuração inicial? Se sim, invés de usar
os parâmetros SSID e senha como abaixo, eles podem ser
carregados da struct wifiConfig.*/
void startWifiMode(byte choice){
  Serial.println(":: WiFi configuration ::");
  Serial.println("Stopping, if any...");
  if (WiFi.isConnected()){
    WiFi.disconnect(true);
    vTaskDelay(pdMS_TO_TICKS(50));
  }
  if (choice == ESP32_MODE_STA){
      Serial.println("Starting station mode...");
      WiFi.begin(ssid, password);
      while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
      }
      esp32_wifi_mode = ESP32_STATE_STA;
      Serial.println(WiFi.localIP());
      Serial.println(":: WiFi Configuration - done ::\n\n");
      webserver.begin();
      return;
  }
  //AP
  if (mqttAlreadyRunning){
    Serial.println("Stop MQTT requires a reset. Doing now..");
    writeFile(SPIFFS, START_AP, "ESP32_STATE_AP");
    vTaskDelete(handleStartMQTT);
    client.disconnect();
    vTaskDelay(pdMS_TO_TICKS(1000));
    ESP.restart();
  }

  Serial.println("Starting access point mode...");
  WiFi.softAP("teste", password);
  Serial.println(WiFi.softAPIP());
  esp32_wifi_mode = ESP32_STATE_AP;
}

loadConfig()

Essa função tem uma sacada legal. Se temos uma struct, normalmente chamados item a item para alimentá-los com dados. Mas e se temos os valores ordenados? Chato fazer linha a linha, não? Ainda mais que os valores vem do arquivo de configuração, portanto, invés de alimentar chamando cada um dos ítens, preferi fazer um loop na struct. E como fazer um loop na struct? Simples!

char *ptr = &wifiConfig.ssid[0];

Gosto bastante de ponteiros, mas não domino amplamente o recurso. Mas nesse caso eu já tinha estudado a respeito dessa forma de acesso ao endereço de memória e, depois de anos, essa é a primeira vez que achei aplicação em um caso real.

Esse comando acima é um ponteiro para o primeiro endereço do primeiro item da estrutura wifiConfig. Como defini o tamanho de todas as variáveis do mesmo tamanho, fica fácil caminhar pelo endereço de memória invés de acessá-los através da struct.

Logo após ter aberto o arquivo na função loadConfig, inicio um loop enquanto houver dados para leitura, lendo linha a linha, delimitado pelo terminador CR ou até o tamanho máximo de 50 Bytes. Depois, mudo para o próximo endereço de memória, que é o incremento do endereço atual acrescido de 50 Bytes. Como o tipo é char, a alocação é de 1 Byte por caractere.

while(file.available()){
    //le uma linha do arquivo.
    file.readBytesUntil('\n', line, MAX_LEN);
    Serial.print("var: ");
    //Serial.println(line);
    //copia a linha para o endereço do ponteiro
    strcpy(ptr,line);
    //imprime o valor guardado no endereço correspondente.
    Serial.println(ptr);
    ////Serial.println(ptr);
    //avança pro próximo endereço
    ptr = ptr+MAX_LEN;
    //limpa a variável que guarda o valor lido do arquivo
    memset(line,0,MAX_LEN);
  }
  file.close();

Esses são os tratamentos essenciais relacionados à manipulação de arquivo. Se quiser uma leitura exclusiva sobre o SPIFFS, sugiro esse artigo, com uma explicação mais simples. Tenho também um artigo de acesso ao sistema de arquivos com Sming e com MicroPython, além de um artigo para reparar o sistema de arquivos.

Webserver com ESP32

Um webserver nada mais é do que um socket aberto enviando dados formatados. Usar o lwip para fazer uma comunicação socket é terrível, como você pode ver nesse artigo. Mas tem uma maneira fácil demais de implementar um socket server:

WiFiServer webserver(80);

Esse socket receberá conexões externas. E para mandar o dado de volta? Lá no loop, cria-se um client para  fazer a transação:

WiFiClient client = webserver.available();

E em seguida vem todo o tratamento que disporei no código completo, mais abaixo. Basicamente é isso, essas duas instâncias são as principais partes do código do servidor web!

Uma última chamada necessária é a server.begin(), que você verá onde ela está sendo chamada no código completo e entenderá o porquê.

MQTT com ESP32

Essa foi a pior parte. Experimentei diversas bibliotecas até chegar à conclusão que “tinha” que ser essa.

Vamos primeiro pensar um pouco a respeito; todos os exemplos das bibliotecas são exclusivamente da biblioteca, logo, dificilmente haverá por aí algum artigo que demonstre a utilização de múltiplos serviços. E acho que principalmente no caso de utilizar um servidor web com MQTT, porque ambos precisam um loop e não devem colidir; um não deve interromper o outro. Como resolver? – digo logo, é fácil com ESP32! Deixei o código HTML (que encontrei em um exemplo) rodando na função loop(), então criei uma task para o MQTT! Processamento assíncrono, um em cada núcleo.

Para iniciar o MQTT, deve-se estar em modo STA. A conexão, a carga das variáveis e a inicialização do MQTT acontecem em uma interrupção de um push button, monitorada por outra task, que faz polling em 4 pinos de IO. Quando o pino de IO relacionado ao MQTT é interrompido, uma task é iniciada, verifica se o MQTT já está rodando (para não chamar de novo), exclui a task que mantém a execução e a inicializa outra vez. Mas antes, faz a carga das variáveis de ambiente (do preLoad), e depois de iniciada a task principal do MQTT, se exclui. Vou mostrar a porção de código relacionada a essas duas tasks:

void vStartMQTT(void *pvParameters){
  /* - ver se a task já está rodando antes de criar. Nao tem vTaskGetInfo() no
  ESP32, mesmo incluindo freertos/task.h e freertos/FreeRTOS.h. Por isso, estou
  fazendo uma exclusão sem verificação.
     - remover a task MQTT se escolher o modo AP.
  */
  if (mqttAlreadyRunning){
     vTaskDelete(mqttConn);
     mqttAlreadyRunning = false;
  }

  preLoad();
  startWifiMode(ESP32_MODE_STA);

  xTaskCreatePinnedToCore(vMQTT, "vMQTT", 10000, NULL, 0, &mqttConn, 0);
  vTaskDelay(pdMS_TO_TICKS(500));
  vTaskDelete(NULL);
}

//Task do MQTT.  O MQTT deverá iniciar quando interrompido no pino X.
void vMQTT(void *pvParameters){
  if (WiFi.status() != WL_CONNECTED){
    Serial.print("WiFi not connected. Waiting for...");
    while(WiFi.status() != WL_CONNECTED){
      Serial.print(".");
      vTaskDelay(pdMS_TO_TICKS(500));
    }
  }
  if (esp32_wifi_mode != ESP32_STATE_STA){
      Serial.println("Mode is not Station.");
      Serial.println(WiFi.getMode());
      vTaskDelete(NULL);
  }

  String ip_addr = wifiConfig.mqtt_host;
  byte first     = ip_addr.indexOf(',');
  byte second    = ip_addr.indexOf(',',first+1);
  byte third     = ip_addr.indexOf(',',second+1);
  byte fourth    = ip_addr.indexOf(',',third+1);

  IPAddress srv(ip_addr.substring(0,first).toInt(),
                ip_addr.substring(first+1,second+1).toInt(),
                ip_addr.substring(second+1,third+1).toInt(),
                ip_addr.substring(third+1,fourth+1).toInt());


  client.setServer(srv, atoi(wifiConfig.mqtt_port));
  client.setCallback(callback);

  mqttAlreadyRunning = true;

  while (true){
    if (!client.connected()) {
      long now = millis();
      if (now - lastReconnectAttempt > 5000) {
        lastReconnectAttempt = now;
        // Attempt to reconnect
        if (reconnect()) {
          lastReconnectAttempt = 0;
        }
      }
    } else {
      // Client connected

      client.loop();
    }
    vTaskDelay(pdMS_TO_TICKS(10));
  }
}

Mas ainda tenho que remover o IPAddres estático daí, esse foi pra teste. O restante fica igual. Em setup(), duas tasks apenas são iniciadas juntas ao sistema; a que faz a carga do arquivo e a que faz polling nos pinos de serviço:

/* Essa task é iniciada no núcleo 0. Quando atende a condição, ela faz a carga do arquivo e se
  finaliza. Quando a carga é feita, é necessário iniciá-la novamente.*/
  xTaskCreatePinnedToCore(vWriteConf, "vWriteConf", 10000, NULL, 0, &writeConf, 0);
  //Verifica estados dos pinos IO27 e IO18. No 27, passa para AP. No 18, escreve as novas confs.
  //A rotina de escrever as confs deve apenas pegar a struct pronta, carregada através do
  //HTML pelo usuário. O HTML pode também mudar o boolean interrupted, assim a tarefa acima já
  //escreverá as novas configs.
  xTaskCreatePinnedToCore(checkButtonConfig, "checkButtonConfig", 10000, NULL, 0, &loadConfs, 0);

Código completo

Algumas outras funções adicionais foram criadas para suprir a negociação entre as execuções. Também tem uma função de log, que criei para auxiliar na depuração, como citei anteriormente. Dela, ainda falta a função para leitura e entrará mais um pino de interrupção para sua leitura.

O código completo no estado atual é esse:

#include <Arduino.h>
#include <WiFi.h>
#include "FS.h"
#include "SPIFFS.h"
#include "rom/gpio.h"
#include <PubSubClient.h>

#define ESP32_MODE_AP  1
#define ESP32_MODE_STA 2

#define LOG_FILE "/esp32-cd.log"
#define START_AP "/start_ap.ini"

#define ESP_INTR_FLAG_DEFAULT 0

//Essa interrupção carrega o arquivo de configuração gravado
#define PIN_TO_CONF  GPIO_NUM_17
//Configurações podem ser definidas pela página? Essa interrupção apenas grava.
#define PIN_TO_INT   GPIO_NUM_18
//inicia o MQTT
#define PIN_TO_MQTT  GPIO_NUM_19
//IO27 para jogar para o modo AP. Fazendo o load das confs, ele inicia em STA e desabilita o MQTT
#define PIN_TO_AP    GPIO_NUM_27

#define ESP32_STATE_OFF 0
#define ESP32_STATE_STA 1
#define ESP32_STATE_AP  2

byte esp32_wifi_mode = ESP32_STATE_OFF;
//manipuladores das tarefas. As tarefas são iniciadas em setup.
TaskHandle_t writeConf; //da interrupção 17, para a função vWriteConf.
TaskHandle_t loadConfs;
TaskHandle_t mqttConn; //da interrupção MQTT.
TaskHandle_t handleStartMQTT; //inicia o mqtt a partir da interrupção

#define RELAY_PIN 12
#define MAX_LEN   50

char VERSION[15] = {0};

const char* ssid     = "essaDefinicaoServeParaTeste";
const char* password = "essaSenhaTambem";

WiFiServer webserver(80);
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();

//string para guardar a requisição http
String header;

char line[50] = {0}; //para leitura de linha do arquivo de configurações

//reconexão do mqtt - registro
long lastReconnectAttempt = 0;

// Auxiliar variables to store the current output state
String output26State = "off";
String output27State = "off";

// Assign output variables to GPIO pins
const int output26 = 26;
const int output27 = 27;

//interrupção
bool interrupted = false;

//como essa versão do ESP-IDF não tem vTaskGetInfo, vamos da meneira deselegante
//para saber se o MQTT já está rodando.
bool mqttAlreadyRunning = false;

/* Se for adicionar mais itens ao array para o arquivo de configuração, esses
itens devem ser colocados juntos aos char e devem ter o mesmo tamanho.
Isso é porque a função que preenche esse array anda no endereço de memória
através de um loop (mais abaixo, na função loadconfig()),
*/
struct configStruct{
  char ssid[MAX_LEN]         = {0};
  char senha[MAX_LEN]        = {0};
  char senha1[MAX_LEN]       = {0};
  char mqtt_host[MAX_LEN]    = {0};
  char mqtt_port[MAX_LEN]    = {0};
  char clientId[MAX_LEN]     = {0};
  char mqtt_pass[MAX_LEN]    = {0};
  char publish_to[MAX_LEN]   = {0};
  char subscribe_to[MAX_LEN] = {0};
  IPAddress ip;
  IPAddress gateway;
} wifiConfig;

//declarações
void preLoad();
void writeFile(fs::FS &fs, const char * path, const char * message);
void readFile(fs::FS &fs, const char * path);
void deleteFile(fs::FS &fs, const char * path);
void appendFile(fs::FS &fs, const char * path, const char * message);
void saveConfig(const char * path);
void loadConfig(const char * path);
void enableInterrupt();
void IRAM_ATTR my_isr_handler(void* arg);
void vButtonPressed(void *pvParameters);
void startWifiMode(byte choice);
void vWriteConf(void *pvParameters);
void logger(fs::FS &fs, const char * path, const char * message);
void vMQTT(void *pvParameters);
void check_if_ap_file_exists(char *path);

//instância pubSub
WiFiClient wifiClient;
PubSubClient client(wifiClient);

/*Como não foi possível interromper o socket aberto apenas interrompendo*/
void check_if_ap_file_exists(char *path){
    if (SPIFFS.exists(path)){
      deleteFile(SPIFFS, path);
      preLoad();
      startWifiMode(ESP32_MODE_AP);
      esp32_wifi_mode = ESP32_STATE_AP;
      webserver.begin();
    }
    else{
      vTaskDelay(pdMS_TO_TICKS(5000));
        Serial.println("AP mode file not found. No connections for now.");
    }
}

bool reconnect() {
  if (client.connect(wifiConfig.clientId,wifiConfig.clientId,wifiConfig.mqtt_pass)) {
    // Once connected, publish an announcement...
    client.publish("/status/","started");
    // ... and resubscribe
    client.subscribe("/relay/#");
  }
  return client.connected();
}

void callback(char* topic, byte* payload, unsigned int length) {
  Serial.println( (char *) payload);
}

void logger(fs::FS &fs, const char * path, const char * message){
    if (fs.exists(LOG_FILE)){
      File log_size = SPIFFS.open(LOG_FILE);
      if (!log_size){
        Serial.println("Couldn't open log file to read. Exiting...");
        return;
      }
      Serial.print("Log file size: ");
      Serial.println(log_size.size());
      size_t sizing = log_size.size();
      //Remove porque o usuário não ficará dando manutenção no sistema de arquivos
      //e esse log é para ajudar em debugs de operação.
      if (sizing > 2048){
        log_size.close();
        deleteFile(SPIFFS, LOG_FILE);
      }
      else{
        appendFile(SPIFFS, LOG_FILE, message);
      }
      return;
    }
    writeFile(SPIFFS,LOG_FILE,message);
}

/* Modo de operação do WiFi (MODE_AP ou MODE_STA).
Existe uma configuração inicial? Se sim, invés de usar
os parâmetros SSID e senha como abaixo, eles podem ser
carregados da struct wifiConfig.*/
void startWifiMode(byte choice){
  Serial.println(":: WiFi configuration ::");
  Serial.println("Stopping, if any...");
  if (WiFi.isConnected()){
    WiFi.disconnect(true);
    vTaskDelay(pdMS_TO_TICKS(50));
  }
  if (choice == ESP32_MODE_STA){
      Serial.println("Starting station mode...");
      WiFi.begin(ssid, password);
      while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
      }
      esp32_wifi_mode = ESP32_STATE_STA;
      Serial.println(WiFi.localIP());
      Serial.println(":: WiFi Configuration - done ::\n\n");
      webserver.begin();
      return;
  }
  //AP
  if (mqttAlreadyRunning){
    Serial.println("Stop MQTT requires a reset. Doing now..");
    writeFile(SPIFFS, START_AP, "ESP32_STATE_AP");
    vTaskDelete(handleStartMQTT);
    client.disconnect();
    vTaskDelay(pdMS_TO_TICKS(1000));
    ESP.restart();
  }

  Serial.println("Starting access point mode...");
  WiFi.softAP("teste", password);
  Serial.println(WiFi.softAPIP());
  esp32_wifi_mode = ESP32_STATE_AP;
}

void vStartMQTT(void *pvParameters){
  /* - ver se a task já está rodando antes de criar. Nao tem vTaskGetInfo() no
  ESP32, mesmo incluindo freertos/task.h e freertos/FreeRTOS.h. Por isso, estou
  fazendo uma exclusão sem verificação.
     - remover a task MQTT se escolher o modo AP.
  */
  if (mqttAlreadyRunning){
     vTaskDelete(mqttConn);
     mqttAlreadyRunning = false;
  }

  preLoad();
  startWifiMode(ESP32_MODE_STA);

  xTaskCreatePinnedToCore(vMQTT, "vMQTT", 10000, NULL, 0, &mqttConn, 0);
  vTaskDelay(pdMS_TO_TICKS(500));
  vTaskDelete(NULL);
}

//Task do MQTT.  O MQTT deverá iniciar quando interrompido no pino X.
void vMQTT(void *pvParameters){
  if (WiFi.status() != WL_CONNECTED){
    Serial.print("WiFi not connected. Waiting for...");
    while(WiFi.status() != WL_CONNECTED){
      Serial.print(".");
      vTaskDelay(pdMS_TO_TICKS(500));
    }
  }
  if (esp32_wifi_mode != ESP32_STATE_STA){
      Serial.println("Mode is not Station.");
      Serial.println(WiFi.getMode());
      vTaskDelete(NULL);
  }

  IPAddress srv(192,168,1,105); //pegar da struct, aqui foi teste
  client.setServer(srv, atoi(wifiConfig.mqtt_port));
  client.setCallback(callback);

  mqttAlreadyRunning = true;

  while (true){
    if (!client.connected()) {
      long now = millis();
      if (now - lastReconnectAttempt > 5000) {
        lastReconnectAttempt = now;
        // Attempt to reconnect
        if (reconnect()) {
          lastReconnectAttempt = 0;
        }
      }
    } else {
      // Client connected

      client.loop();
    }
    vTaskDelay(pdMS_TO_TICKS(10));
  }
}

/* Se criar uma variável no HTML para carregar o número de versão, será possível
manter várias versões diferentes, porém na hora de carregar automaticamente, só
virá a que está definida em setup(). Essa pode ser a conf padrão, ou a única conf,
se preferir.
*/
//interrompe na ISR, pino IO18. Essa task faz polling e então executa a carga.
void vWriteConf(void *pvParameters){
    while (true){
      if (interrupted){
        vTaskDelay(pdMS_TO_TICKS(1000));
        interrupted = false;
        //Faz o load das variaveis do HTML aqui.
        Serial.println("Writing new confs");
        saveConfig(VERSION);
      }
      vTaskDelay(pdMS_TO_TICKS(10));
    }
}

//ativa interrupção no pino 18
void enableInterrupt(){
  //equivalente a pinMode() do Arduino
  gpio_set_pull_mode(PIN_TO_INT,GPIO_PULLUP_PULLDOWN);
  //sem isso, as interrupções não funcionam
  gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
  //tipo da interrupção
  gpio_set_intr_type(PIN_TO_INT, GPIO_INTR_HIGH_LEVEL);
  //habilita interrupção no pino X
  gpio_intr_enable(PIN_TO_INT);
  //manipulador
  gpio_isr_handler_add(PIN_TO_INT, my_isr_handler, NULL);
}

//configuração inicial se não houver arquivo de versão ou se falhar ao tentar
//abrir o arquivo. Se essa função for chamada para pré-carregar a struct, então
//seria ideal ter o SSID e password para AP e STA.
void preLoad(){
  strcpy(wifiConfig.ssid,"ssidDaRede");
  strcpy(wifiConfig.senha,"senha");
  strcpy(wifiConfig.senha1,"senha");
  strcpy(wifiConfig.mqtt_host,"192.168.1.10");
  strcpy(wifiConfig.mqtt_port,"1883");
  strcpy(wifiConfig.clientId,"dobitaobyte");
  strcpy(wifiConfig.mqtt_pass,"senha");
  strcpy(wifiConfig.subscribe_to,"/listen");
  strcpy(wifiConfig.publish_to,"/status");

  wifiConfig.ip      = IPAddress(192,168,1,123);
  wifiConfig.gateway = IPAddress(192,168,1,1);
}

//declaração da ISR
void IRAM_ATTR my_isr_handler(void* arg){
    xSemaphoreGiveFromISR(xSemaphore, pdFALSE);
    interrupted = true;
}

//loadConfig - Ao apertar o botão de carga de configuração, carrega e inicia wifi.
/* O caminho do arquivo no sistema de arquivos sempre deve começar com "/".
O define VERSION guarda esse valor e está logo após os includes.
*/
void loadConfig(fs::FS &fs, const char * path){
  Serial.printf("Reading file: %s\n", path);

  if (path[0] != '/'){
    Serial.println("File path needs start with /. Change it.");
    return;
  }

  //Se o arquivo de versão não existir, pre-carrega essas configurações.
  if (!SPIFFS.exists(path)){
    Serial.println("File does not exists. Preloading...");
    Serial.print("Version: ");
    Serial.println(VERSION);
    preLoad();
    //Salva o arquivo de versão no sistema de arquivos e sai.
    saveConfig(path);
    return;
  }

  //Se o arquivo existe, então essa função vem direto para cá.
  //Se não conseguir abrir o arquivo, sai.
  File file = fs.open(path);
  if(!file || file.isDirectory()){
      Serial.println("Failed to open file for reading");
      //se deu problema, carrega as configurações padrão.
      preLoad();
      return;
  }

  /* Abriu o arquivo, agora vem uma diversão*/
  Serial.println("Read from file: ");
  /* Invés de ler 1 linha e alimentar 1 item, esse ponteiro abaixo pega o
  endereço de memória onde o array foi alocado. Depois, lê-se do arquivo de
  configurações em um loop, procurando por CR. Daí copia-se o array de
  char do arquivo para o endereço de memória correspondente e avança-se para
  o próximo endereço, onde estará o byte 0 do próximo item.
  O valor foi definido no define MAX_LEN e o tipo é char.*/
  char *ptr = &wifiConfig.ssid[0];

  while(file.available()){
    //le uma linha do arquivo.
    file.readBytesUntil('\n', line, MAX_LEN);
    Serial.print("var: ");
    //Serial.println(line);
    //copia a linha para o endereço do ponteiro
    strcpy(ptr,line);
    //imprime o valor guardado no endereço correspondente.
    Serial.println(ptr);
    ////Serial.println(ptr);
    //avança pro próximo endereço
    ptr = ptr+MAX_LEN;
    //limpa a variável que guarda o valor lido do arquivo
    memset(line,0,MAX_LEN);
  }
  file.close();
  /*Modos: ESP32_MODE_STA ou ESP32_MODE_AP. Esse modo pode vir da carga das configurações.*/
  startWifiMode(ESP32_MODE_STA);
}

/* Essa função salva as configurações (que já estarão atribuidas previamente)
no arquivo cujo nome é passado por parâmetro.
*/
//O nome do arquivo deve ser precedido por "/", para dizer que o arquivo está
//na raiz do sistema.
void saveConfig(const char * path){
    if (SPIFFS.exists(path)){
      Serial.println("File exists. Removing previous... ");
      deleteFile(SPIFFS, path);
      vTaskDelay(pdMS_TO_TICKS(100));
    }
    writeFile(SPIFFS,path,wifiConfig.ssid);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.senha);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.senha1);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.mqtt_host);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.mqtt_port);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.clientId);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.mqtt_pass);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.publish_to);
    appendFile(SPIFFS, path, "\n");
    appendFile(SPIFFS, path, wifiConfig.subscribe_to);
    appendFile(SPIFFS, path, "\n");
}

//se apertar o botão, executa a carga das configurações e aguarda 3.01ms antes
//de repetir a leitura
/*Essa é uma task que fica fazendo polling no pino que terá o botão para
carregar as configurações.*/
void checkButtonConfig(void *pvParameters){
  vTaskDelay(pdMS_TO_TICKS(500));
  while (true){
    if (digitalRead(PIN_TO_CONF)){
      Serial.println("Load config requested from button (IO17)");
      loadConfig(SPIFFS,VERSION);
      vTaskDelay(pdMS_TO_TICKS(3000));
    }
    else if (digitalRead(PIN_TO_AP)){
      startWifiMode(ESP32_MODE_AP);
      //só pra não repetir antes de tirar o dedo do botão...
      vTaskDelay(pdMS_TO_TICKS(5000));
    }
    else if (digitalRead(PIN_TO_MQTT)){
        xTaskCreatePinnedToCore(vStartMQTT, "vStartMQTT", 10000, NULL, 0, &handleStartMQTT, 0);
        vTaskDelay(pdMS_TO_TICKS(3000));
    }
    vTaskDelay(pdMS_TO_TICKS(10));
  }
}

/* Chamado dentro da função de carga*/
void writeFile(fs::FS &fs, const char * path, const char * message){
    Serial.printf("Writing file: %s\n", path);

    /* cria uma variável "file" do tipo "File", então chama a função
    open do parâmetro fs recebido. Para abrir, a função open recebe
    os parâmetros "path" e o modo em que o arquivo deve ser aberto
    (nesse caso, em modo de escrita com FILE_WRITE).
    O nome de arquivo deve começar com /
    */
    if (path[0] != '/'){
      Serial.println("Path needs start with / before filename. File not created.");
      return;
    }

    if (SPIFFS.exists(path)){
      Serial.println("File exists. Delete before write it again. Exiting.");
      return;
    }

    File file = fs.open(path, FILE_WRITE);
    //verifica se foi possivel criar o arquivo
    if(!file){
        Serial.println("Failed to open file for writing");
        return;
    }
    /*grava o parâmetro "message" no arquivo. Como a função print
    tem um retorno, ela foi executada dentro de uma condicional para
    saber se houve erro durante o processo.*/
    if(file.print(message)){
        Serial.println("File written");
    } else {
        Serial.println("Write failed");
    }
    file.close();
}

//chamado na função de carga.
void readFile(fs::FS &fs, const char * path){
    Serial.printf("Reading file: %s\n", path);

    if (path[0] != '/'){
      Serial.println("File path needs start with /. Change it.");
    }

    File file = fs.open(path);
    if(!file || file.isDirectory()){
        Serial.println("Failed to open file for reading");
        return;
    }

    Serial.println("Read from file: ");
    while(file.available()){
        Serial.write(file.read());
    }
    file.close();
}

//Pode-se ter diferentes versões do arquivo de configuração guardadas no
//sistema de arquivos. Para escolher qual acessar, basta mudar o nome do
//arquivo no define VERSION. Quando quiser excluir, basta chamar essa função
//passando o nome do arquivo.
void deleteFile(fs::FS &fs, const char * path){
    if (path[0] != '/'){
      Serial.println("File path needs start with /. Change it.");
    }
    Serial.printf("Deleting file: %s\n", path);
    if(fs.remove(path)){
        Serial.println("File deleted");
        return;
    }
    Serial.println("Delete failed");
}

//chamada na função que grava o arquivo.
void appendFile(fs::FS &fs, const char * path, const char * message){
    if (path[0] != '/'){
      Serial.println("File path needs start with /. Change it.");
    }

    Serial.printf("Appending to file: %s\n", path);

    File file = fs.open(path, FILE_APPEND);
    if(!file){
        Serial.println("Failed to open file for appending");
        return;
    }

    if(file.print(message)){
        Serial.println("Message appended");
    } else {
        Serial.println("Append failed");
    }
    file.close();
}

// --------------------- *SETUP* --------------------------------------
void setup(){
  Serial.begin(115200);
  strcpy(VERSION, "/version-0.1");
  //pins
  //pinMode(LED_BUILTIN,OUTPUT);
  pinMode(PIN_TO_CONF,INPUT);
  pinMode(PIN_TO_MQTT,INPUT);
  //configuração do pino de interrupção.
  gpio_set_direction(PIN_TO_INT, GPIO_MODE_INPUT);

  //Habilita a ISR
  enableInterrupt();

  //monta o sistema de arquivos.
  if (!SPIFFS.begin(true)){
    Serial.println("Couldn't mount the filesystem.");
  }

  //Inicia o modo AP se existir o arquivo ini. Isso é necessário porque não dá
  //pra cancelar o socket em execução.
  check_if_ap_file_exists((char *)START_AP);
  //deleteFile(SPIFFS, VERSION);

  /* Essa task é iniciada no núcleo 0. Quando atende a condição, ela faz a carga do arquivo e se
  finaliza. Quando a carga é feita, é necessário iniciá-la novamente.*/
  xTaskCreatePinnedToCore(vWriteConf, "vWriteConf", 10000, NULL, 0, &writeConf, 0);
  //Verifica estados dos pinos IO27 e IO18. No 27, passa para AP. No 18, escreve as novas confs.
  //A rotina de escrever as confs deve apenas pegar a struct pronta, carregada através do
  //HTML pelo usuário. O HTML pode também mudar o boolean interrupted, assim a tarefa acima já
  //escreverá as novas configs.
  xTaskCreatePinnedToCore(checkButtonConfig, "checkButtonConfig", 10000, NULL, 0, &loadConfs, 0);

}

void loop(){
  vTaskDelay(pdMS_TO_TICKS(1));
  WiFiClient client = webserver.available();   // Listen for incoming clients

  if (client) {                             // If a new client connects,
    Serial.println("New Client.");          // print a message out in the serial port
    String currentLine = "";                // make a String to hold incoming data from the client
    while (client.connected()) {            // loop while the client's connected
      if (client.available()) {             // if there's bytes to read from the client,
        char c = client.read();             // read a byte, then
        Serial.write(c);                    // print it out the serial monitor
        header += c;
        if (c == '\n') {                    // if the byte is a newline character
          // if the current line is blank, you got two newline characters in a row.
          // that's the end of the client HTTP request, so send a response:
          if (currentLine.length() == 0) {
            // HTTP headers always start with a response code (e.g. HTTP/1.1 200 OK)
            // and a content-type so the client knows what's coming, then a blank line:
            client.println("HTTP/1.1 200 OK");
            client.println("Content-type:text/html");
            client.println("Connection: close");
            client.println();

            // turns the GPIOs on and off
            if (header.indexOf("GET /26/on") >= 0) {
              Serial.println("GPIO 26 on");
              output26State = "on";
              digitalWrite(output26, HIGH);
            } else if (header.indexOf("GET /26/off") >= 0) {
              Serial.println("GPIO 26 off");
              output26State = "off";
              digitalWrite(output26, LOW);
            } else if (header.indexOf("GET /27/on") >= 0) {
              Serial.println("GPIO 27 on");
              output27State = "on";
              digitalWrite(output27, HIGH);
            } else if (header.indexOf("GET /27/off") >= 0) {
              Serial.println("GPIO 27 off");
              output27State = "off";
              digitalWrite(output27, LOW);
            }

            // Display the HTML web page
            client.println("<!DOCTYPE html><html>");
            client.println("<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
            client.println("<link rel=\"icon\" href=\"data:,\">");
            // CSS to style the on/off buttons
            // Feel free to change the background-color and font-size attributes to fit your preferences
            client.println("<style>html { font-family: Helvetica; display: inline-block; margin: 0px auto; text-align: center;}");
            client.println(".button { background-color: #4CAF50; border: none; color: white; padding: 16px 40px;");
            client.println("text-decoration: none; font-size: 30px; margin: 2px; cursor: pointer;}");
            client.println(".button2 {background-color: #555555;}</style></head>");

            // Web Page Heading
            client.println("<body><h1>ESP32 Web Server</h1>");

            // Display current state, and ON/OFF buttons for GPIO 26
            client.println("<p>GPIO 26 - State " + output26State + "</p>");
            // If the output26State is off, it displays the ON button
            if (output26State=="off") {
              client.println("<p><a href=\"/26/on\"><button class=\"button\">ON</button></a></p>");
            } else {
              client.println("<p><a href=\"/26/off\"><button class=\"button button2\">OFF</button></a></p>");
            }

            // Display current state, and ON/OFF buttons for GPIO 27
            client.println("<p>GPIO 27 - State " + output27State + "</p>");
            // If the output27State is off, it displays the ON button
            if (output27State=="off") {
              client.println("<p><a href=\"/27/on\"><button class=\"button\">ON</button></a></p>");
            } else {
              client.println("<p><a href=\"/27/off\"><button class=\"button button2\">OFF</button></a></p>");
            }
            client.println("</body></html>");

            // The HTTP response ends with another blank line
            client.println();
            // Break out of the while loop
            break;
          } else { // if you got a newline, then clear currentLine
            currentLine = "";
          }
        } else if (c != '\r') {  // if you got anything else but a carriage return character,
          currentLine += c;      // add it to the end of the currentLine
        }
      }
    }
    // Clear the header variable
    header = "";
    // Close the connection
    client.stop();
    Serial.println("Client disconnected.");
    Serial.println("");
  }

}
//BKP: WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_STA); WiFi.mode(WIFI_AT);
//ESP-IDF: Não funciona o vTaskGetInfo; desconectar o MQTT não libera o socket;
//modo AP levanta em paralelo com o STATION. Solução: softReset e arquivo ini.

Espero que tenha gostado e, vou tentar gravar um video em um ou dois dias. Já tenho outro pendente aqui, aproveito pra subir ambos. Não deixe de se inscrever no nosso canal DobitAoByteBrasil no Youtube e clique no sininho pra receber notificações. Só subo vídeos que realmente considero importantes, você não será incomodado por pouco, ok?

 

 

 

 

Djames Suhanko

Djames Suhanko é Perito Forense Digital. Já atuou com deployer em sistemas de missão critica em diversos países pelo mundão. Programador Shell, Python, C, C++ e Qt, tendo contato com embarcados ( ora profissionalmente, ora por lazer ) desde 2009.

Um comentário em “ESP32 com MQTT, servidor web e sistema de arquivos

Fechado para comentários.