Servidor web WiFi com Arduino e ESP8266

Um servidor web com Arduino e ESP8266, para controlar um led e ler o estado de sensores pelo navegador ou celular, via Wifi, sem bibliotecas externas nem apps.

Muito do que se faz em aplicações sem fio com microcontroladores pode ser descrito, em última análise, em operações de leitura remota ou acionamento remoto das portas desses microcontroladores.

Muitas vezes essas operações usam protocolos simples, em que microcontroladores e computadores se comunicam entre si para envio de eventos, alarmes, comandos e respostas, e a interface com o usuário é reservada a algum nó privilegiado da rede, que reune todas as informações e tem capacidade de exibição delas, bem como de receber comandos.

Mas quando o microcontrolador tem acesso a serviços de redes de mais alto nível, como os da web, existe a possibilidade de fazer a interface diretamente entre um aplicativo de usuário final – como um navegador web – e o microcontrolador.

Nosso exemplo de hoje usa um módulo ESP-01 – modelo econômico contendo o popular chip ESP8266 (leitura recomendada: “ESP8266 do jeito simples”) – como interface WiFi de um Arduino, para permitir que o programa do Arduino controle a recepção de comandos e o retorno de páginas web apresentando o status de seus sensores ou de execução dos comandos recebidos.

Para exemplificar, apresentarei um programa capaz de prover páginas web correspondentes a 5 URLs diferentes, incluindo algumas que descrevem o estado de combinações de sensores analógicos e digitais, e duas que definem comandos: um para acender e outro para apagar o led interno do Arduino.

Para poder lidar com todos os níveis da solução, esse programa de exemplo não faz uso de nenhuma biblioteca que não venha instalada na IDE do Arduino.

O microcontrolador do Arduino é de 8 bits, funciona a 16MHz e tem meros 2KB de RAM, e todos esses fatores limitam bastante o desempenho e a capacidade de um servidor web rodando nele, portanto modere as suas expectativas.

Mas, para demonstrar as possibilidades, as páginas geradas pelo programa fazem uso de formatação HTML com cores, curvas e outros elementos de formatação, e não dependem de nenhum arquivo externo.

Na prática, é provável que você vá preferir usar uma resposta mais sucinta do Arduino (por exemplo, um arquivo XML ou um fluxo CSV sem qualquer formatação) a ser combinada a recursos de formatação servidos por algum outro equipamento, mas no nosso exemplo colocaremos tudo junto, para demonstrar as técnicas e possibilidades. Reduzi-las depois a um XML ou CSV é bem fácil ;-)

Vale destacar que seria viável, com vantagens, fazer uso direto da capacidade de processamento do ESP8266, sem o Arduino envolvido. Futuramente abordaremos essa possibilidade, mas hoje o ESP estará relegado ao papel secundário de mero acessório de um processador com menos performance que a dele ;-)

Também cabe mencionar que meu programa foi desenvolvido para uso pessoal em intranets e redes locais. Se você planeja expô-lo à Internet ou a uso público, além das questões de roteamento (ou NAT, proxy reversa, tunelamento e outras técnicas para fazer a Interner "enxergar" o seu ESP8266, todas fora do escopo deste artigo), é importante pensar nos aspectos de segurança e controle de acesso, que não foram considerados de maneira extensiva no desenvolvimento deste exemplo.

Montagem física: 2 Arduinos, um ESP

A montagem do circuito deste artigo usa 2 Arduinos, sendo um deles (o da direita na foto) o responsável pela operação como um todo, incluindo o gerenciamento do ESP8266, e o segundo atuando apenas como um conversor USB-serial extra, para permitir que o Arduino principal defina uma porta serial adicional, virtual, para enviar mensagens de acompanhamento (e de erro) para o monitor serial, já que ele vai usar sua porta serial interna para controlar o ESP8266.

Você pode usar apenas um Arduino, se inverter as seriais (conectar o ESP8266 à serial virtual, e usar a serial nativa para conectar via USB ao monitor serial). A alteração no programa para fazer isso é simples, mas não será abordada neste artigo.

Para funcionar no singelo papel de adaptador USB-serial extra para dar acesso ao monitor serial, o segundo Arduino (à esquerda na foto) deve estar rodando o programa de exemplo Blink (ou Bare Minimum) que acompanha a IDE1, e será usado como descrito no artigo “Usando outro Arduino como intermediário para debug no Monitor com a softwareSerial” (podendo ser substituído por um módulo USB-Serial, como descrito no artigo “Biblioteca softwareSerial: conexão alternativa para debug via Monitor Serial”).

O pino 10 do Arduino da direita é conectado ao pino 1 do Arduino da esquerda, e o pino 11 do Arduino da direita deve estar conectado ao pino 0 do Arduino da esquerda. Para visualizar as mensagens do programa, é necessário abrir o Monitor Serial conectado à porta USB do Arduino da esquerda, e configurado para velocidade 38400.

No casamento entre Arduino e ESP8266, é necessário compatibilizar as tensões.

O ESP8266 funciona no nível lógico 3,3V, menor que os 5V típicos do Arduino Uno e similares. Para facilitar a minha vida, usei um Arduino Uno Plus, que tem chave seletora de nível lógico e assim pode operar a 3,3V – mesma tensão do ESP8266 – mas há outras alternativas à sua disposição, como veremos abaixo.

O ESP8266 é conectado ao Arduino Uno Plus exatamente como já vimos no artigo anterior “ESP8266 do jeito simples”:

  • Os pinos GND e 3.3V são de alimentação elétrica, e foram conectados ao GND e 3V3 do Arduino Uno Plus, respectivamente.
  • Os pinos CH_PD e RST foram conectados aos pinos digitais 5 e 6, respectivamente, sendo que o primeiro é permanentemente mantido em HIGH, e o segundo fica LOW brevemente na inicialização do meu programa (causando um reset do ESP8266) e a partir daí fica permanentemente em HIGH.
  • Já os pinos RX e TX são os responsáveis pela comunicação de dados entre Arduino e ESP8266. O RX recebe bits (e deve ser conectado ao TX do Arduino – o pino 1, no caso do Uno), e o TX envia bits (e deve ser conectado ao RX do Arduino - o pino 0, no caso do Uno).

Todas as conexões de alimentação, sinal e dados mencionadas precisam considerar a tensão do ESP8266, que é de 3,3V, bem abaixo dos 5V típicos dos pinos de dados do Arduino. Eu optei por usar um Arduino cuja tensão dos pinos de dados é configurável e tem opção de 3,3V, mas teria outras alternativas, como os simples e eficazes divisores de tensão construídos com um par de resistores, ou mesmo CIs conversores de nível lógico.

O programa

O programa é longo, e está apresentado com colorização, para facilitar a descrição que vem depois dele:

// Servidor web stand-alone com Arduino e ESP8266 - Augusto Campos 2015

#define debug
#define rede "pinoquio"
#define senha "mentira"

const byte maxPagina=80;
const byte linhasPagina=7;
const char htmlCorpo[linhasPagina] [maxPagina] PROGMEM = {
 { "<head><title>BR-Arduino ESP8266</title><style>#q{float:left;height:240px;"         },
 { "width:240px;margin:10px;border-radius:12px;background:#336699;}#l{font-size:"      },
 { "64px;color:#ffcc66;margin-top:82px;width:100%;height:32px;text-align:center;}"     },
 { "body{width: 260px; margin-left: auto; margin-right: auto; border:"                 },
 { "1px solid #d0d0d0;font-family:sans-serif;font-size:32px;font-weight:bold;"         },
 { "border-radius:25px;text-align:center;}a{text-decoration:none}</style>"             },
 { "</head><body><b>BR-Arduino.org</b><p>"                                             }
};

int montaPagina(char *url, char *pagina) {
  byte tipo=255;
  if (0==strcmp(url,"/1")) tipo=0;
  else if (0==strcmp(url,"/3")) tipo=1;
  else if (0==strcmp(url,"/d")) tipo=2;
  else if (0==strcmp(url,"/ac")) tipo=3;
  else if (0==strcmp(url,"/ap")) tipo=4;
  pagina[0]='∖0';
  for (byte i=0; i<linhasPagina; i++) {  
    char S[maxPagina];
    memcpy_P(&S,&htmlCorpo[i],sizeof S);
    strcat(pagina,S);
  }
  char umSensor[3];
  const char* predivs="∖n∖r<div id=q><div id=l>";
  const char* posdivs="</div></div>";
  const char* sepmenu="</a><br><a href=/";
  switch (tipo) {
  case 0:    
    itoa(analogRead(A3),umSensor,10);
    strcat(pagina,predivs);
    strcat(pagina,umSensor);
    strcat(pagina,posdivs);
    break;
  case 1:    
    itoa(analogRead(A3),umSensor,10);
    strcat(pagina,predivs);
    strcat(pagina,umSensor);
    strcat(pagina,posdivs);
    itoa(analogRead(A4),umSensor,10);
    strcat(pagina,predivs);
    strcat(pagina,umSensor);
    strcat(pagina,posdivs);
    itoa(analogRead(A5),umSensor,10);
    strcat(pagina,predivs);
    strcat(pagina,umSensor);
    strcat(pagina,posdivs);
    break;
  case 2:    
    itoa(analogRead(A3),umSensor,10);
    strcat(pagina,predivs);
    strcat(pagina,umSensor);
    strcat(pagina,posdivs);
    itoa(analogRead(A4),umSensor,10);
    strcat(pagina,predivs);
    strcat(pagina,umSensor);
    strcat(pagina,posdivs);
    strcat(pagina,predivs);
    if (digitalRead(12)==HIGH) strcat(pagina,"HIGH");
    else strcat(pagina,"LOW");
    strcat(pagina,posdivs);
    break;
  case 3:
    digitalWrite(13,HIGH);
    strcat(pagina,predivs);
    strcat(pagina,"OK ♦");
    strcat(pagina,posdivs);
    break;
  case 4:    
    digitalWrite(13,LOW);
    strcat(pagina,predivs);
    strcat(pagina,"OK ◊");
    strcat(pagina,posdivs);
    break;
  default:
    strcat(pagina,"<a href=/ac>acende");
    strcat(pagina,sepmenu);
    strcat(pagina,"ap>apaga");
    strcat(pagina,sepmenu);
    strcat(pagina,"1>1 sensor");
    strcat(pagina,sepmenu);
    strcat(pagina,"3>3");
    strcat(pagina,sepmenu);
    strcat(pagina,"d>c/ digital</a>");
  } 
  return strlen(pagina);  
}  

// a partir daqui o codigo eh generico
// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv \

const byte CH_PD=5;
const byte RST=6;

#include <SoftwareSerial.h>
SoftwareSerial monitorSerial(11, 10); // RX, TX

boolean aguardaResposta(const char *sucesso, unsigned long limite=7000, boolean mostrar=false) {
  char resp[90];
  unsigned long chegada=millis();
  unsigned long tempo;
  boolean continuar=true; 
  boolean timeout=false;
  int contaChars=0;
  while (continuar) { 
    if (Serial.available()) {
      resp[contaChars] = Serial.read();
      contaChars++;
      if (contaChars>88) contaChars=0;  // aqui deveria haver uma condicao de erro
#ifndef debug
      if (mostrar) {
        monitorSerial.print(resp[contaChars-1]);
      }
#else
      monitorSerial.print(resp[contaChars-1]);
#endif      
      if (resp[contaChars-1]==10) {  // LF, fim da linha recebida
        if (contaChars>1) {
          resp[contaChars-2]='∖0';
          if (0==strcmp(resp,"OK")) continuar=false;
          else if (0==strcmp(resp,"SEND OK")) continuar=false;
          else if (0==strcmp(resp,"ready")) continuar=false;
          else if (0==strcmp(resp,"error")) continuar=false;
          else if (0==strcmp(resp,"ERROR")) continuar=false;
          else if (0==strcmp(resp,"Unlink")) continuar=false;
          contaChars=0;
        }  
      }  
    }  
    tempo=millis()-chegada;
    if (tempo > limite) {
      timeout=true;
      continuar=false;
    }  
  }
  boolean retorno=false;
  if (!timeout & (0==strcmp(resp,sucesso))) retorno=true;  
  return retorno;
} 

void descreve(const char *msg) {
  monitorSerial.print("** ");
  monitorSerial.println(msg);
}

void erro(int codigo, const char *msg) {
  monitorSerial.print("!! ERRO ");
  monitorSerial.println(msg);
  if (codigo<100) {
    descreve("Pressione RESET.");
    while(true) {  /* loop vazio sem fio para travar execucao */ } 
  }  
}

boolean resetESP() {
  descreve("Reset fisico ESP");
  digitalWrite(RST,LOW);
  delay(100);
  digitalWrite(RST,HIGH);
  delay(3000);
  descreve("Reset logico ESP");
  Serial.println("AT+RST");  
  aguardaResposta("OK",200); // aqui soh confirma a recepcao do comando
  return aguardaResposta("ready",2000);
}  

boolean conectaWiFi() {
  descreve("Conec. wifi");
  char comando[100]="AT+CWJAP=\"";
  strcat(comando,rede);
  strcat(comando,"\",\"");
  strcat(comando,senha);
  strcat(comando,"\"");
  Serial.println(comando);  
  return aguardaResposta("OK",10000);
}  

boolean obtemIP() {
  descreve("Obtendo IP - use o segundo");
  Serial.println("AT+CIFSR");  
  return aguardaResposta("OK",5000,true);
}  

boolean ativaServidor() {
  descreve("Ativ. MUX");
  Serial.println("AT+CIPMUX=1");  
  if (aguardaResposta("OK",500)) {
    descreve("Ativ. porta 80");
    Serial.println("AT+CIPSERVER=1,80");  
    return aguardaResposta("OK",500);
  }  
  else return false;
}  

boolean recebeComandoWeb(char *idconexao, char *url) {
  const char *sucesso="OK";
  unsigned long limite=7000;
  unsigned long chegada=millis(); 
  char resp[514]="";
  unsigned long tempo;
  boolean continuar=true; 
  boolean timeout=false;
  int contaChars=0;
  while (continuar) { 
    if (Serial.available()) {
      unsigned long chegada=millis();   // recomeca a contar quando ha recepcao
      resp[contaChars] = Serial.read();
      contaChars++;
      if (contaChars>512) contaChars=0;  // aqui deveria haver uma condicao de erro
#ifdef debug
      monitorSerial.print(resp[contaChars-1]);
#endif
      if (resp[contaChars-1]==10) {  // LF, fim da linha recebida
        if (contaChars>1) {
          resp[contaChars-2]='∖0';
          if (0==strcmp(resp,"OK")) continuar=false;
          else if (0==strcmp(resp,"error")) continuar=false;
          else if (0==strcmp(resp,"ERROR")) continuar=false;
          else if (0==strcmp(resp,"Unlink")) continuar=false;
          else if (0==strcmp(resp,"Link")) continuar=false;
          else {
            if (strstr(resp,"+IPD,")) {
              descreve("Rec. requisicao web"); 
              // o ID da conexao fica entre virgulas
              char *conexaoStr=strstr(resp,",")+1;
              char *pos=strstr(conexaoStr,",");
              if (pos!=0) {
                strncpy(idconexao,conexaoStr,pos-conexaoStr);
                idconexao[pos-conexaoStr]='∖0';
                // a URL solicitada fica entre espacos
                char *urlStr=strstr(resp," ")+1;
                char *pos=strstr(urlStr," ");
                if (pos!=0) {
                  strncpy(url,urlStr,pos-urlStr);
                  url[pos-urlStr]='∖0';
                }  
              }  
            }
          }  
          contaChars=0;
        }  
      }  
    }  
    tempo=millis()-chegada; // tempo sem recepcao
    if (tempo > limite) {
      timeout=true;
      continuar=false;
    }  
  }
  boolean retorno=false;
  if (!timeout & (0==strcmp(resp,sucesso))) retorno=true; 
  return retorno;
}

void ESPsendStr(const char *conexao, const char *ESPlinha) {
  int comprimento=strlen(ESPlinha);
  char comando[30]="AT+CIPSEND=";
  strcat(comando,conexao);
  strcat(comando,",");
  Serial.print(comando);
  Serial.println(comprimento,DEC);
  Serial.find("> ");  
  Serial.print(ESPlinha);
  aguardaResposta("SEND OK");
}  

boolean servePagina(char *idconexao, char *url) {  
#ifdef debug
  monitorSerial.print(F("Servindo a pagina da URL "));
  monitorSerial.print(url);
  monitorSerial.print(F(" na conexao "));
  monitorSerial.println(idconexao);
#endif      
  char paginaMontada[600]="";
  int tamanhoHTML=montaPagina(url,paginaMontada);
  ESPsendStr(idconexao,"HTTP/1.1 200 OK∖r∖nContent-Type: text/html∖r∖n");
  ESPsendStr(idconexao,"Connection: close∖r∖nContent-Length: ");
  char tamanhoSend[4];
  itoa(tamanhoHTML,tamanhoSend,10);
  ESPsendStr(idconexao,tamanhoSend);
  ESPsendStr(idconexao,"∖r∖n∖r∖n");
  byte fatias=tamanhoHTML/78;
  for (byte f=0; f<=fatias; f++) {
    char buf[80];
    char *umaFatia=&paginaMontada[f*78];
    strncpy(buf,umaFatia,78);
    buf[78]='∖0';
    ESPsendStr(idconexao,buf);
  }  
  aguardaResposta("SEND OK");
}  

void setup() {
  // bloco 1 - inicializacao especifica da aplicacao
  pinMode(13,OUTPUT);
  pinMode(12,INPUT_PULLUP);
  // bloco 2 - inicializacao fisica generica para o ESP
  Serial.begin(9600);
  Serial.setTimeout(5000);
  monitorSerial.begin(38400);
  pinMode(RST,OUTPUT);
  pinMode(CH_PD,OUTPUT);
  digitalWrite(CH_PD,HIGH);
  // bloco 3 - inicializacao logica do ESP e operacao do servidor
  if (resetESP()) {
    if (conectaWiFi()) {
      if (obtemIP()) {
        if (ativaServidor()) {
          descreve("Aguardando conexoes");
          while (true) {   // loop sem fim
            char url[64]="";
            char idconexao[3]="";
            if (recebeComandoWeb(idconexao,url)) {
              if ((url[0]!='∖0') & (idconexao[0]!='∖0')) {
                servePagina(idconexao,url);
              }  
            }  
          }
        } 
        else erro(5,"servidor");
      } 
      else erro(4,"IP");
    } 
    else erro(3,"WiFi");
  } 
  else erro(2,"reset");  
}

void loop() {
  // intencionalmente vazio
}

Vamos analisar o código do final para o começo, e iniciar notando que a função obrigatória loop(), do Arduino, está vazia. A funcionalidade dela foi substituída por um loop sem fim definido explicitamente (e marcado com um comentário, para facilitar a identificação) dentro da função setup(), logo acima.

Já o setup(), também obrigatório, está bem definido. Ele é executado automaticamente uma vez, imediatamente após o boot, e no nosso exemplo está dividido em 3 blocos distintos e demarcados:

  • Inicialização específica da aplicação – no nosso caso, é a mera definição dos pinos que serão lidos ou controlados pela nossa página web de exemplo.
  • Inicializacao física genérica para o ESP – define o estado dos pinos que controlam o modo de operação do ESP8266, inicializa a porta serial interna (física) do Uno usada para se comunicar com o ESP8266, a porta serial lógica (virtual) usada para enviar mensagens de acompanhamento e debug para o monitor serial2. Para entender como funciona essa serial virtual, leia o artigo Usando outro Arduino como intermediário para debug no Monitor com a softwareSerial.
  • Inicialização lógica do ESP e operação do servidor – aqui está definida a sequência funcional básica do programa, que chama as demais funções (marquei-as em negrito no trecho de código) para controlar um reset do ESP8266, conectá-lo à rede WiFi (cujo nome e senha estão definidos nas linhas iniciais do programa), obter e mostrar no monitor um endereço IP para o nosso servidor, ativar o servidor web na porta 80 desse endereço IP e, finalmente, iniciar um loop sem fim no qual o Arduino recebe comandos pelo servidor web do ESP8266 e responde a eles servindo páginas.

Preparação do ESP8266 e tratamento de erros

Vamos agora tratar das funções chamadas pelo setup, começando pelo trio que inicializa o ESP8266, e está em verde claro no código: resetESP(), conectaWiFi() e obtemIP().

Todas fazem uso dos comandos AT que vieram definidos no firmware do meu módulo ESP-01, e a funcionalidade delas já foi descrita em detalhes no meu artigo introdutório sobre o ESP8266, portanto vamos tratar delas apenas rapidamente: a primeira faz um reset físico (controlando um pino específico do ESP8266) seguido de um comando de reset lógico, garantindo assim que o módulo WiFi vá estar no seu estágio inicial, sem conexões nem operações em andamento; a segunda instrui o ESP8266 a se conectar à rede cujo nome e senha estão definidos nas linhas iniciais do programa, e a terceira faz com que o ESP8266 solicite ao roteador, receba e informe um endereço IP para uso pelo nosso servidor.

Minhas funções de inicialização do ESP8266 verificam o completamento, e oferecem a possibilidade de tratamento de erro pelo programa.

O que elas têm de novidade em relação ao artigo introdutório é um suporte básico a tratamento de erros: cada uma delas monitora a resposta que o ESP8266 retorna após a operação comandada, e retorna ao chamador (no nosso caso, o setup()) a confirmação de que a resposta foi a esperada – ou de que houve erro.

Nosso tratamento de erros é bem simples: o setup() as chama como parte de comandos if, cujas cláusulas else (que, neste caso, são executadas no caso do retorno informando que a resposta do ESP não foi a esperada) chamam a função erro(), que exibirá no terminal uma informação básica sobre a situação de erro e interromperá a execução até que o usuário pressione reset, como no exemplo a seguir:

** Conec. wifi
AT+CWJAP="pinoquio","mentira"
!! ERRO WiFi
** Pressione RESET.

Você pode torná-lo mais avançado implementando uma forma de tentar novamente a operação que falhou, de exibir a condição de erro mesmo sem uso do monitor serial, ou mesmo provocando um novo reset em caso de falha irrecuperável sem retornar aos passos anteriores.

Ativação do servidor web do Arduino na porta 80

Depois que o ESP8266 está conectado à rede WiFi e recebeu um endereço IP, já podemos ativar nele as tarefas de suporte ao nosso servidor web com Arduino, tarefa da função ativaServidor(), em verde musgo.

Ela é responsável por duas ações: preparar o ESP8266 para receber múltiplas conexões simultâneas (modo MUX), e ativar o recebimento de conexões de rede na porta TCP 80, que é a porta oficialmente reservada para o protocolo http, base da web.

O ESP8266 recebe as requisições, mas não as atende: ele avisa ao Arduino, que se encarrega de processá-las e comandar a resposta.

Quando o recebimento de conexões está ativado no ESP da forma como defini, o próprio módulo fica pronto para oferecer, negociar, aceitar, e depois transferir para uma função de controle rodando no Arduino os contatos recebidos de outros computadores da rede – no nosso caso, os acessos e requisições enviados por navegadores.

A função ativaServidor() simplesmente ativa esse modo, e depois a sua responsabilidade encerra – o tratamento das conexões recebidas será feito por outra função. Mas ela também tem o método de tratamento de erro descrito acima, e a função setup() será informada caso a resposta à ativação do modo MUX ou do início do recebimento de conexões não for a esperada.

Recebendo pedidos do navegador e respondendo a eles

Após ativar o servidor, nossa implementação de servidor web dá início, no setup(), a um loop sem fim que aguarda por solicitações enviadas por navegadores3 e, quando as recebe, serve a eles uma página web.

A função de recepção de comandos, que veremos em detalhes a seguir, retorna ao setup() a URL solicitada pelo navegador, e o número da conexão – porque o ESP permite múltiplas conexões simultâneas, embora a nossa rotina de tratamento esteja muito melhor aparelhada para tratar uma conexão de cada vez4.

O setup() atua como um despachante, recebendo de uma função a URL solicitada pelo cliente, e encaminhando para outra função que irá atendê-la.

De posse da URL solicitada e do número da conexão retornados, o setup() chama a função que serve uma página (nós a veremos detalhadamente a seguir), passando a ela como parâmetro a URL solicitada e a conexão. Após seu retorno, o loop recomeça, para receber mais solicitações e servir páginas a elas, indefinidamente.

A implementação reserva 64 caracteres para a URL, e não faz nenhum tratamento para prevenir que o navegador solicite uma URL mais longa que estoure esse limite. O mesmo é válido para outras strings recebidas pelo programa: se for usar na prática, acrescente esse tratamento de limites, caso contrário estará sujeito a travamentos e à possibilidade de negação de serviço ou mesmo de manipulação indevida da execução.

Recebendo conexões web no Arduino, em detalhes

A função recebeComandoWeb(), em roxo, recebe como parâmetros 2 strings C5, nas quais retornará a URL solicitada pelo navegador que entrar em contato, e o número da conexão, que normalmente será 0, a não ser que tenhamos que tratar múltiplas conexões simultâneas.

Ela também retornará um status, que será verdadeiro no caso de uma conexão ter sido recebida, e falso em caso de erro ou timeout – e esse status permitirá à função chamadora (no caso, o setup()) saber se deve executar um comando ou tratar um erro.

A função recebeComandoWeb() precisa saber interpretar requests e cabeçalhos http enviados pelo navegador.

Após iniciar uma conexão com a porta TCP 80, o navegador envia requisições na forma de strings de múltiplas linhas (cada uma delas terminada com 2 caracteres especiais: CR e LF, com códigos ASCII 13 e 10, respectivamente), das quais a primeira é o chamado request, que informa qual a URL desejada e o método e protocolo da transferência, e as demais são os request headers, que indicam se ele aceita conexões compactadas, se aceita cache, qual o idioma configurado no ambiente do usuário, qual o formato de codificação de caracteres, etc. – cada navegador e configuração produz um conjunto diferente de linhas.

Antes de entregar ao Arduino essas linhas, o ESP8266 acrescenta a elas algumas informações extras, incluindo uma que é fundamental para nossa operação: o número da conexão, para o caso de haver conexões simultâneas.

Um exemplo de conexão recebida, destacando em negrito as partes que são acrescentadas pelo ESP8266, e em cores as partes que chegaremos a tratar no programa:

+IPD,0,118:GET /teste HTTP/1.1
User-Agent: Wget/1.15 (darwin13.1.0)
Accept: */*
Host: 192.168.0.11
Connection: Keep-Alive
 
OK

O navegador envia o trecho que começa na palavra GET e termina na linha em branco. Tudo que vem ali (incluindo a linha em branco, que é obrigatória) atende às definições do protocolo http, e em um programa mais avançado poderíamos vir a precisar tratar várias das linhas, ou suas possíveis variações. Neste nosso programa simples, o único tratamento que damos é procurar a URL, que vem na primeira linha e destaquei em vermelho.

Antes de repassar o trecho ao programa, o ESP8266 acrescenta o prefixo que começa com +IPD6, e uma linha de status (geralmente OK) ao final. O prefixo contém uma informação importante, que destaquei em verde: o número da conexão. Já a linha de status permite saber que a transmissão acabou, e se foi bem-sucedida.

Para receber requisições, é necessário unir os caracteres para formar linhas, e aí identificar em cada linha as informações relevantes, como a URL solicitada.

Assim, o funcionamento básico da função recebeComandoWeb() é o de um loop de recebimento de caracteres, que procura pelo caracter indicador de final de linha (LF, código ASCII 10) e, ao encontrá-lo, trata a linha que tiver sendo recebida, para identificar se ela inicia com +IPD. Se iniciar, procura na linha o identificador da conexão e a URL, e as armazena nas variáveis corretas, para retorná-las ao final.

O loop continua até ocorrer um timeout (7 segundos), ou a linha recebida indicar a conclusão de uma transmissão de comando – situações em que o ESP8266 apresenta uma linha contendo apenas "OK" ou "ERROR".

Como temos essa memória sobrando, reservamos um buffer de até 512 caracteres para a recepção de cada linha, mas em aplicações práticas pode ser necessário reduzir bastante o tamanho desse buffer, e lidar melhor com a possibilidade de a linha recebida ser maior do que o espaço reservado para ela.

Vale destacar que esse buffer é armazenado só enquanto a função está em execução, e depois o espaço reservado para ele é liberado, voltando a ser reservado apenas na próxima execução da função.

Servindo uma página web no Arduino, em detalhes

Após receber uma URL e um número de conexão em uma conexão bem-sucedida, o setup() passa essas informações como parâmetro para a função servePagina(), cujo código está destacado em verde esmeralda, logo acima da função setup().

O que a função servePagina() faz é reservar (durante a execução da função) um buffer de 600 caracteres no qual ela solicita que seja montada uma página web completa correspondente à URL solicitada, a ser transmitida em seguida ao navegador que fez a solicitação.

A montagem é feita pela função montaPagina(), que veremos adiante. O que a servePagina() faz, após chamar a montaPagina(), é enviar ao nevegador o cabeçalho obrigatório definido no protocolo http, e em seguida enviar a página que foi montada.

O cabeçalho http da resposta tem formato similar à requisição http que vimos acima: é uma sequência de linhas terminadas por CR e LF, e complementadas por uma linha em branco que indica o final da transmissão do cabeçalho.

Nosso programa transmite um cabeçalho bem simples:

HTTP/1.1 200 OK
Content-Type: text/html
Connection: close
Content-Length: 593

Exceto o último número, que marquei em negrito e é calculado a partir do número de caracteres da página que foi gerada, o restante é fixo, e indicará para o navegador a versão do protocolo utilizado, a ausência de erro, o tipo de conteúdo a ser transmitido a seguir, e a forma de controle de final de conexão que usaremos (“Connection: close” indica que o navegador deve encerrar a conexão ao final da transmissão da página pelo ESP), além do tamanho da página que será servida.

Após mandar o cabeçalho para o ESP8266 usando várias chamadas da minha função ESPsendStr() (que veremos a seguir), o programa roda um loop que divide a página gerada em várias fatias de 787 caracteres cada uma, e as transmite ao ESP8266 usando a mesma função ESPsendStr().

No modo debug, essa transmissão aparece no monitor Serial como no exemplo a seguir:

<head><title>BR-Arduino ESP8266</title><style>#q{float:left;height:240px;width
SEND OK
:240px;margin:10px;border-radius:12px;background:#336699;}#l{font-size:64px;co
SEND OK
lor:#ffcc66;margin-top:82px;width:100%;height:32px;text-align:center;}body{wid
SEND OK
th: 260px; margin-left: auto; margin-right: auto; border:1px solid #d0d0d0;fon
SEND OK

Ao final, a função aguarda por uma resposta adicional "SEND OK", que é gerada pelo ESP8266 após uma transmissão bem-sucedida, mas na verdade não há uma expectativa de recebê-la – é apenas uma oportunidade para receber e ignorar a mensagem "Unlink", que é gerada na desconexão física, tratada à parte pelo ESP8266. Assim, neste ponto não há tratamento de erro caso o "SEND OK" ou o "Unlink" não sejam recebidos – lidar com conexões e desconexões físicas é mais uma oportunidade de melhoria do programa para o caso de você vir a usar esse programa em aplicações práticas.

Vale destacar que a função genérica aguardaResposta() chamada neste ponto tem funcionamento interno praticamente idêntico ao da recebeComandoWeb(), diferindo principalmente na forma de contagem do timeout e na definição das condições de encerramento. Certamente seria possível unir em uma função os trechos que elas têm em comum.

ESPsendStr: a função genérica que envia páginas web e cabeçalhos

Enviar cada linha do cabeçalho http e do conteúdo web para o ESP8266 transmitir ao navegador é uma tarefa com várias etapas, todas cumpridas pela função genérica ESPsendStr(), em verde-piscina, que eu desenvolvi para remover das funções de mais alto nível essa complexidade repetitiva.

A ESPsendStr() recebe como parâmetro um identificador de conexão e uma string de caracteres a transmitir, que podem ser um ou mais cabeçalhos http8 ou trechos de página web, e se encarrega de instruir o ESP8266 a transmitir esse conteúdo a um navegador que tenha feito uma solicitação.

O que a função faz é:

  • transmitir ao ESP8266 o comando CIPSEND9, passando como parâmetros o identificador da conexão e o número de caracteres da string
  • aguardar o ESP8266 apresentar o prompt > que indica que a string pode ser enviada
  • enviar a string
  • aguardar a resposta SEND OK do ESP8266

Não há tratamento de erro para o caso do SEND OK não chegar, ou uma mensagem de erro chegar no lugar dele - é mais uma oportunidade de melhoria da robustez.

Montando a nossa página web

Note que – com exceção de 2 linhas no primeiro bloco da função setup()tudo que vimos até aqui são funções genéricas, sem nenhuma especificidade referente à página que vamos gerar ou aos pinos do Arduino que vamos manipular.

O que vimos até agora foram operações essenciais, tais como receber uma solicitação, transmitir uma página (com qualquer conteúdo). Elas poderiam perfeitamente ser incorporadas a uma biblioteca para facilitar o reuso de suas funções, e na prática eu certamente farei isso, assim que precisar voltar a mexer nesse código.

Agora chegamos ao momento de lidar com o que é específico da nossa página e aplicação de exemplo, e que eu reuni na função montaPagina(), em azul escuro, bem no alto do programa.

Já vimos que a montaPagina() é chamada pela servePagina(). Ela recebe como parâmetro a url solicitada pelo navegador, e o buffer no qual deverá armazenar a página que vai montar10. Ela retorna – além do conteúdo do buffer – o tamanho da página que gerou, que precisará ser mencionado no cabeçalho http, como já vimos.

O cabeçalho não varia, mas o formato e conteúdo da página dependem da URL solicitada.

A montaPagina() sabe montar 5 tipos diferentes de página, respondendo a 5 URLs diferentes.

A URL armazenada no programa é apenas a parte final, posterior ao endereço do servidor. Por exemplo, vamos imaginar que o seu ESP8266 esteja funcionando no IP 192.168.1.200, e que você o tenha acessado pelo navegador buscando o endereço http://192.168.1.200/teste – neste caso, a variável url vai conter o valor correspondente ao trecho em laranja, ou seja, /teste.

Os tipos de página definidos na montaPagina() são:

  • se a URL for "/1", exibe o valor de 1 sensor analógico (o A3).
  • se a URL for "/3", exibe o valor de 3 sensores analógicos (A3, A4 e A5).
  • se a URL for "/d", exibe o valor de 2 sensores analógicos e 1 digital (A3, A4, 12)
  • se a URL for "/ac", acende o led interno do pino 13, e confirma que acendeu
  • se a URL for "/ap", apaga o led interno do pino 13, e confirma que apagou
  • para qualquer outra URL (incluindo a "/"), exibe uma feiosa página de erro indicando links para as 5 URLs válidas.
NOTA IMPORTANTE: a maioria dos navegadores desktop modernos trabalha com o conceito de preload, que inclui tentar carregar a página desejada antes mesmo que o usuário termine de digitar seu endereço. Se você estiver com o monitor serial ligado, será possível observar esse tipo de tentativa, que pode atrapalhar bastante os testes, já que o Arduino já estará interagindo com o preload no momento em que você pressionar Enter, e nosso programa não tem grande robustez quando se trata de lidar com conexões simultâneas. Para evitar problemas durante os testes, evite digitar as URLs na barra do navegador: acesse a página "/" e siga os links a partir dela, ou – caso conheça a técnica – use um cliente web ou navegador via linha de comando, como o links, o lynx, o curl ou o wget.

Após identificar, a partir da URL, qual o tipo de página que precisa ser servido, a função segue com seu procedimento normal:

  1. preencher o buffer pagina[] com o trecho fixo de HTML e CSS armazenado na variável htmlCorpo residente na memória flash do Arduino usando a técnica detalhada neste artigo: Mais memória no Arduino: indo além dos 2KB de RAM com a PROGMEM.
  2. acrescentar ao buffer pagina[] um ou mais blocos de conteúdo, conforme o caso, correspondentes ao tipo de página que foi identificado.
  3. retornar o buffer pagina[] e seu tamanho.

Note que o mesmo trecho da estrutura switch/case que monta os trechos específicos da página também se encarrega de consultar o valor dos sensores, e de manipular o pino do led interno, conforme a URL que estiver sendo atendida.

O modo debug

Você deve ter percebido alguns trechos em cor de rosa no código, todos contendo alguma referência à palavra debug.

Eles usam diretivas de compilação condicional, que permitem compilar o programa de maneiras diferentes com base em alguma condição – neste caso, a presença ou ausência de uma linha contendo #define debug, que – não por acaso – está bem no alto do nosso programa de exemplo.

Sempre que o programa for compilado com o #define debug, a compilação condicional será acionada, e as funções básicas de comunicação do programa enviarão para o monitor serial virtual cópias ou descrições das mensagens que o Arduino estiver trocando com o ESP8266.

Se você remover o #define debug, os trechos em rosa serão ignorados na compilação, e o programa enviará para o monitor serial virtual apenas informações essenciais sobre as operações que estiver executando a cada momento, bem como o único parâmetro que não pode deixar de ser consultado quando se usa endereçamento dinâmico: o IP fornecido ao ESP8266 pelo roteador, que você precisará usar para acessá-lo via navegador.

 
  1.  Para garantir que nenhum programa vai interferir na atividade que precisamos que seu hardware desempenhe.

  2.  Se você quisesse invertê-las, além de trocar as posições dos jumpers, seria necessário trocar os nomes delas aqui.

  3.  Essas solicitações são recebidas pelo servidor que ativamos na porta 80.

  4.  Tratar adequadamente múltiplas conexões simultâneas servindo páginas web completas em uma CPU de 8 bits com 2KB de RAM não é impossível, mas foge ao meu escopo de interesse.

  5.  Na forma de pointers para arrays de char.

  6.  Geralmente precedido por uma linha contendo apenas o indicativo "Link", que meu programa recebe e ignora.

  7.  O número 78 foi definido considerando a RAM disponível e o desempenho desejado, valores maiores ou menores podem ser arbitrados para cada caso.

  8.  Note que a função servePagina() envia 2 cabeçalhos por vez, e ao final envia o Content-Length quebrado em 2 transmissões distintas, e depois envia o corpo da página web quebrado em fatias arbitrárias de 78 caracteres cada.

  9.  A sintaxe é AT+CIPSEND=conexão,tamanho – saiba mais nesta lista de comandos AT do ESP8266.

  10.  Essa forma de operar, copiando a página inteira para um buffer, é bastante limitada pela quantidade de RAM disponível no Arduino. Se você precisar ir além do tamanho que usei no programa, provavelmente precisará montar a página em várias etapas, sem armazená-la integralmente antes de transmiti-la.

Comentar

Dos leds ao Arduino, ESP8266 e mais

Aprenda eletrônica com as experiências de um geek veterano dos bits e bytes que nunca tinha soldado um led na vida, e resolveu narrar para você o que descobre enquanto explora esse universo – a partir da eletrônica básica, até chegar aos circuitos modernos.

Por Augusto Campos, autor do BR-Linux e Efetividade.net.

Recomendados

Livro recomendado


Artigos já disponíveis

Comunidade Arduino

O BR-Arduino é integrante da comunidade internacional de entusiastas do Arduino, mas não tem relação com os criadores e distribuidores do produto, nem com os detentores das marcas registradas.

Livros recomendados