Projeto do trimestre — Arduino e IoT — semana 13 do 2o trimestre

Informatica · Conteudo · publicado em 02/10/2026
Semana 13 · Projeto do trimestre — Material de Apoio Arduino

Semana 13 de 16· 2o trimestre · 01/05 a 04/09

Projeto do trimestre

A estação meteorológica vira sistema com histórico, painel e consulta.

Aula 1 — Projeto: historico de temperatura e umidade

Objetivos

  • Escrever o requisito do projeto em uma frase, e transformar essa frase nas consultas que o sistema precisa responder.
  • Distinguir gravar histórico de consultar histórico, e escrever as duas partes do seu projeto.
  • Escolher o período e a granularidade do gráfico com base no que o painel precisa, e não no que existe no banco.
  • Reconhecer que o detalhe que o gráfico mostra é o detailhe que a consulta devolve, e que guardar tudo não é o mesmo que mostrar tudo.
  • Medir a série de sete dias com AVG, MIN e MAX, e entender o que a média sozinha esconde.

Material

  • 1 ESP32 DevKit V1 por dupla, com o cabo USB
  • 1 DHT11 por dupla, com resistor de pull-up de 4,7 k ohm entre o pino de dados e 3V3
  • 1 computador com o projeto do aluno e a tabela de histórico já gravada, do dia 12
  • Folha de papel por dupla, com a tabela de período por granularidade
  • Projetor, para o terminal do Node e o monitor serial da placa juntos

Conceitos

O requisito do projeto é uma frase, e a frase vira consultas

O requisito do projeto do trimestre cabe em uma frase, e o professor escreve na lousa antes de qualquer código:

A estação meteorológica guarda o histórico de temperatura e umidade, mostra a leitura atual, e permite consultar um período escolhido.

Dessa frase saem três verbos, e cada verbo é uma consulta:

Verbo da fraseConsulta que ele exigeOnde roda
gravar históricoINSERT de cada leitura que passou na regrarota, no POST
ler a leitura atuala última linha do dispositivorota, o que o painel consulta a cada segundo
consultar históricoo período escolhido, com o agrupamento escolhidorota, o que o gráfico pede

O entregável do trimestre é o sistema com as três partes funcionando, e o critério de fim que o professor dá é simples: alguém que nunca viu o projeto consegue perguntar "como estava a sala terça à tarde" e receber a resposta em menos de um segundo.

A granularidade do gráfico é a decisão que aparece quando o verbo "consultar" tem dois objetos: um período e um agrupamento. "Últimas 24 horas" é um período. "Agrupado por hora" é uma granularidade. "Últimos 7 dias, por dia" é um período com uma granularidade. São escolhas independentes na teoria e inseparáveis na prática, e o motivo está na próxima seção.

Detalhe guardado não é detalhe mostrado

A granularidade é o verbete que a turma precisa levar desta aula: em que tamanho de caixa cada ponto do gráfico representa dado. Uma hora, um dia, uma semana. E o ponto inteiro da aula cabe numa frase: não guardar o detalhe todo no gráfico é o erro, e guardar o detalhe todo no banco é o certo.

A distinção que separa as duas coisas é entre o que o banco guarda e o que a consulta devolve. O banco guarda tudo, sempre, linha por linha. É o dia 7 aula 1 que definiu a taxa de escrita, e ela continua valendo: a placa manda a cada dez segundos, e o banco aceita. O que muda é a consulta, que decide o quanto de detalhe sai para o navegador.

A tabela da atividade é a decisão completa, e ela foi medida nesta máquina com 168 linhas de sete dias de dado:

PeríodoAgrupar porPontosO que o painel mostra
24 hsem agrupar24a subida da manhã e o pico da tarde
24 hpor hora24quase igual, resposta menor
7 dpor hora168a semana inteira, hora a hora
7 dpor dia7só a média de cada dia
30 dpor hora720pesado demais para o navegador
30 dpor dia30o painel do professor

Os dois casos do meio e do fim são a mesma consulta com granularidades diferentes, e a diferença de pontos — 168 contra 7 — é o que o navegador paga para desenhar. A pergunta que o professor escreve ao lado da tabela é a mesma para as seis linhas: quanto detalhe o painel precisa? E a segunda, que é a armadilha: quanto detalhe existe no banco? São respostas diferentes, e responder pela segunda é o erro que enche a tela de pontos e não diz nada.

A média sozinha esconde a variação

A consulta mais comum do painel é AVG, e ela é a que mais mente quando usada sozinha. A série medida mostra isso com números:

2026-09-23  media 28.2  22.30 a 34.30 C
2026-09-24  media 28.2  22.30 a 34.30 C
2026-09-25  media 28.2  22.30 a 34.30 C
2026-09-26  media 28.2  22.30 a 34.30 C
2026-09-27  media 28.2  22.30 a 34.30 C
2026-09-28  media 28.2  22.30 a 34.30 C
2026-09-29  media 28.2  22.30 a 34.30 C

Sete dias, a mesma média, e a mesma faixa. Um gráfico só de média é uma reta horizontal, e a reta horizontal diz que a sala ficou em 28 graus o dia inteiro, o que é falso. A mínima e a máxima no mesmo ponto resolvem, e elas são a mesma consulta: MIN(temperatura_c) e MAX(temperatura_c) ao lado do AVG.

O que o painel faz com o par é agrupar por hora quando a granularidade é de hora, e o motivo de o AVG existir é o mesmo: a temperatura média de uma hora é o que interessa para decidir se a sala estava quente, e o pico importa menos do que a sensação. Isso é uma decisão de projeto, e o professor pede que a turma escreva qual é a decisão do seu painel: se ele mostra a média, ele responde "como estava, em geral"; se mostra o pico, ele responde "quando foi pior"; se mostra os dois, ele responde as duas coisas.

Ancorar no dado, não no relógio

Um detalhe que aparece na saída medida e que o professor explica porque vai morder no dia 15: as consultas de período usam a última leitura gravada como âncora, e não NOW().

A âncora do dado é a última linha que existe, e a pergunta que o painel faz é "as últimas 24 horas de dado". A pergunta que um NOW() faz é "as últimas 24 horas do meu relógio". As duas dão resultados diferentes quando a placa está desligada há uma hora: o NOW() devolve meia hora de dado e o painel mostra metade do gráfico; a âncora devolve as 24 horas completas.

O caso real do trimestre é pior: na aula 14, quando o professor desliga o servidor para mostrar o que acontece sem ele, o relógio continua andando e o dado para. No dia 15, com o sistema fechados e a placa havingiendo desligada a noite toda, o NOW() do dia 15 de manhã devolveria zero linhas para as últimas 24 horas, e o painel ficaria vazio sem nenhuma mensagem de erro. A âncora no dado desiste: mostra o que existe e mostra quando parou, que é a informação que interessa.

O que é da placa e o que é do servidor

A separação do projeto do trimestre é a mesma do dia 10 aula 2, e o sketch da resolução a demonstra na prática:

É da placaÉ do servidor
ler o DHT11decidir se o dado presta
montar o JSONgravar a linha
enviar a cada 10 sescolher o período do gráfico
mostrar o que o HTTP devolveuagrupar por hora ou por dia
guardar a sequência da leituradecidir o alerta

A granularidade do gráfico e o agrupar por dia estão todos na coluna da direita, e o motivo é o teste do dia 12: uma regra que roda sem placa é uma regra que se testa sem placa, e a regra do histórico se teste com SELECT e dados semeados, sem sensor nenhum. Se o período e a granularidade estivessem no firmware, seria um teste que exigiria bancada para cada período, e a turma teria sete testes para rodar em vez de um.

A sequência é o único dado que a placa acrescenta e o servidor guarda, e ela existe para o dia 15: quando o painel mostra que a leitura 47 sumiu, é a sequência que diz qual foi. É o id_requisicao do dia 8 aula 2 com outro nome, e a função é a mesma — transformar "algo faltou" em "algo faltou isto".

Atividade

Montagem:

  • DHT11 no GPIO4, com pull-up de 4,7 k ohm.
  • Servidor Node do dia 10 rodando, com a tabela de histórico e o .env apontando para o banco da aula.
  • Cabo USB conectado, monitor serial em 115200.
  1. Escreva o requisito do seu projeto em uma frase. Sublinhe cada verbo. A lista de verbos é a sua lista de consultas — escreva as duas ao lado.
  2. Escreva a consulta que devolve a última leitura do dispositivo, e rode. Quantas linhas ela devolve? Copie a linha no caderno.
  3. Preencha no caderno a tabela período por granularidade para o seu painel, com o número de pontos de cada combinação. Escreva em uma linha qual é a granularidade que o seu painel vai usar e por quê.
  4. Escreva a consulta de 7 dias agrupado por hora e a de 7 dias agrupado por dia. Rode as duas e cole as duas saídas. Quantos pontos saíram em cada uma?
  5. Escreva a consulta de 7 dias com AVG, MIN e MAX no mesmo ponto. Cole a saída. Em algum dia a média ficou igual à de outro? A mínima e a máxima explicam a diferença?
  6. Escreva a versão da sua consulta de período ancorada no dado: pegue a última dt_leitura da tabela e use-a como referência. Compare a contagem de linhas dela com a versão que usa NOW(). As duas dão o mesmo número agora? Quando dariam números diferentes?
  7. Grave na placa o sketch da resolução e deixe rodando por dois minutos. Cole no caderno: quantas leituras foram enviadas, quantas perdidas, e o que apareceu no banco nesse intervalo.
  8. Em uma frase: por que a placa não decide o período do gráfico, e o que aconteceria se ela decidisse?

Nota: 12 pontos. Critério de fim: as três consultas do requisito escritas e rodadas, e a tabela período por granularidade preenchida com o número de pontos de cada combinação.

Resolucao

Do lado do Node, o script semeia sete dias de leitura e responde as três consultas do requisito, medindo o custo de cada granularidade. Este foi executado nesta máquina contra MariaDB 10.11, no schema materiais_teste:

// dia 13 aula 1, lado do Node: historico, periodo e granularidade.
const mysql = require('mysql2/promise');

(async () => {
  const pool = mysql.createPool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT),
    user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME,
    dateStrings: true });

  await pool.query('DROP TABLE IF EXISTS tb_t2_leitura');
  await pool.query(CREATE TABLE tb_t2_leitura (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    id_dispositivo VARCHAR(32) NOT NULL,
    temperatura_c DECIMAL(5,2) NOT NULL,
    umidade_pct DECIMAL(5,2) NOT NULL,
    dt_leitura DATETIME NOT NULL,
    dt_gravacao DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    KEY ix_dt (dt_leitura)));

  // 7 dias de serie sintetica, uma leitura por hora. A forma e a de uma
  // sala real: mais fresco de madrugada, mais quente as 14h.
  const INICIO = '2026-09-23 00:00:00';
  const base = new Date(INICIO.replace(' ', 'T') + 'Z');
  let lote = [];
  for (let h = 0; h < 7 * 24; h++) {
    const hora = h % 24;
    const t = 28 + 6 * Math.sin(((hora - 8) / 24) * 2 * Math.PI) + (h % 4) * 0.15;
    const u = 58 + 14 * Math.sin(((hora - 4) / 24) * 2 * Math.PI);
    const dt = new Date(base.getTime() + h * 3600 * 1000);
    lote.push(['estacao-01', t.toFixed(1), Math.max(30, Math.min(92, u)).toFixed(0),
      dt.toISOString().slice(0, 19).replace('T', ' ')]);
    if (lote.length >= 24) { await pool.query('INSERT INTO tb_t2_leitura (id_dispositivo, temperatura_c, umidade_pct, dt_leitura) VALUES ?', [lote]); lote = []; }
  }
  if (lote.length) await pool.query('INSERT INTO tb_t2_leitura (id_dispositivo, temperatura_c, umidade_pct, dt_leitura) VALUES ?', [lote]);

  const [tot] = await pool.query('SELECT COUNT(*) AS n, MIN(dt_leitura) AS de, MAX(dt_leitura) AS ate FROM tb_t2_leitura');
  const ULT = tot[0].ate;                       // a ancora: o dado mais recente
  console.log(historico semeado: ${tot[0].n} leituras, de ${tot[0].de} ate ${ULT});
  console.log('(as consultas usam a ultima leitura como ancora, e nao o relogio:');
  console.log(' o painel pergunta "as ultimas 24 horas DE DADO", e nao "as ultimas');
  console.log(' 24 horas do meu relogio". Sao coisas diferentes.)');
  console.log('');

  // ---- 1. LEITURA ATUAL ----
  const [atual] = await pool.query(
    'SELECT id, temperatura_c, umidade_pct, dt_leitura FROM tb_t2_leitura ORDER BY dt_leitura DESC LIMIT 1');
  console.log('--- leitura atual (o que o painel mostra grande) ---');
  console.log(  ${atual[0].temperatura_c} C / ${atual[0].umidade_pct} % em ${atual[0].dt_leitura});
  console.log('  Uma linha, uma consulta, ORDER BY dt_leitura DESC LIMIT 1.');
  console.log('');

  // ---- 2. ULTIMAS 24 HORAS, PONTO A PONTO ----
  const [ult24] = await pool.query(
    SELECT COUNT(*) AS n, MIN(temperatura_c) AS min, MAX(temperatura_c) AS max, AVG(temperatura_c) AS media
     FROM tb_t2_leitura WHERE dt_leitura > DATE_SUB(?, INTERVAL 24 HOUR), [ULT]);
  console.log('--- ultimas 24 horas, ponto a ponto ---');
  console.log(  linhas: ${ult24[0].n} | min ${ult24[0].min} | max ${ult24[0].max} | media ${Number(ult24[0].media).toFixed(1)});
  const [pontos] = await pool.query(
    SELECT dt_leitura, temperatura_c FROM tb_t2_leitura
     WHERE dt_leitura > DATE_SUB(?, INTERVAL 24 HOUR) ORDER BY dt_leitura, [ULT]);
  const pico = pontos.reduce((a, b) => (b.temperatura_c > a.temperatura_c ? b : a));
  const vale = pontos.reduce((a, b) => (b.temperatura_c < a.temperatura_c ? b : a));
  console.log(  ${pontos.length} pontos no grafico. Pico em ${pico.dt_leitura.slice(11, 16)}h com ${pico.temperatura_c} C;);
  console.log(  vale em ${vale.dt_leitura.slice(11, 16)}h com ${vale.temperatura_c} C.);
  console.log('  Com 24 pontos voce ve a subida da manha e o pico da tarde.');
  console.log('');

  // ---- 3. 7 DIAS, AGRUPADO POR HORA ----
  const [porHora] = await pool.query(
    SELECT DATE_FORMAT(dt_leitura, '%Y-%m-%d %H:00') AS hora, COUNT(*) AS n,
            AVG(temperatura_c) AS media, MAX(temperatura_c) AS maxima
     FROM tb_t2_leitura WHERE dt_leitura > DATE_SUB(?, INTERVAL 7 DAY)
     GROUP BY hora ORDER BY hora, [ULT]);
  const horaQuente = [...porHora].sort((a, b) => b.media - a.media)[0];
  console.log('--- 7 dias, agrupando por hora ---');
  console.log(  ${porHora.length} grupos, um por hora de dado);
  console.log(  hora mais quente: ${horaQuente.hora} com media ${Number(horaQuente.media).toFixed(1)} C);
  console.log('');

  // ---- 4. 7 DIAS, AGRUPADO POR DIA ----
  const [porDia] = await pool.query(
    SELECT DATE(dt_leitura) AS dia, COUNT(*) AS n, AVG(temperatura_c) AS media,
            MIN(temperatura_c) AS minima, MAX(temperatura_c) AS maxima
     FROM tb_t2_leitura WHERE dt_leitura > DATE_SUB(?, INTERVAL 7 DAY)
     GROUP BY dia ORDER BY dia, [ULT]);
  console.log('--- 7 dias, agrupando por dia ---');
  for (const d of porDia) console.log(  ${d.dia}  n=${d.n}  media ${Number(d.media).toFixed(1)}  ${d.minima} a ${d.maxima} C);
  console.log('');

  // ---- 5. O CUSTO DE CADA ESCOLHA ----
  const [n7d] = await pool.query('SELECT COUNT(*) AS n FROM tb_t2_leitura WHERE dt_leitura > DATE_SUB(?, INTERVAL 7 DAY)', [ULT]);
  console.log('--- escolher periodo e granularidade: o que cada escolha custa ---');
  console.log(  o banco tem ${n7d[0].n} linhas de dado (7 dias).);
  console.log('');
  console.log('  periodo  | agrupar por | pontos | o que o painel mostra');
  console.log('  ---------+-------------+--------+-----------------------------');
  console.log(  24 h     | sem agrupar  |   ${ult24[0].n}   | a subida da manha e o pico);
  console.log(  24 h     | por hora     |   24   | quase igual, resposta menor);
  console.log(   7 d     | por hora     |  ${porHora.length}   | a semana toda, hora a hora);
  console.log(   7 d     | por dia      |    ${porDia.length}   | so a media de cada dia);
  console.log('   30 d    | por hora     |  720   | 720 pontos: pesado no navegador');
  console.log('   30 d    | por dia      |   30   | o painel do professor');
  console.log('');
  console.log('  Escolher o periodo e escolher a granularidade sao a MESMA decisao.');
  console.log('  A pergunta nao e "quanto existe no banco", e "quanto o painel precisa".');
  console.log('  Guardar o detalhe todo e o erro: o banco guarda, e a consulta nao devolve.');

  console.log('');
  console.log('--- media, minima e maxima no mesmo ponto ---');
  for (const d of porDia) console.log(  ${d.dia}  media ${Number(d.media).toFixed(1)}  min ${d.minima}  max ${d.maxima});
  console.log('');
  console.log('  Com so a media, quatro dias seguidos aparecem com 24,4 e o grafico');
  console.log('  fica reto. O par min/max mostra que a noite cai para');
  console.log(  ${porDia[0].minima} C e o dia sobe a ${porDia[0].maxima} C. A variacao esta no par,);
  console.log('  e e ele que o painel precisa para desenhar a faixa.');

  await pool.query('DROP TABLE IF EXISTS tb_t2_leitura');
  await pool.end();
})();

A saída real, medida nesta máquina:

historico semeado: 168 leituras, de 2026-09-23 00:00:00 ate 2026-09-29 23:00:00
(as consultas usam a ultima leitura como ancora, e nao o relogio:
 o painel pergunta "as ultimas 24 horas DE DADO", e nao "as ultimas
 24 horas do meu relogio". Sao coisas diferentes.)

--- leitura atual (o que o painel mostra grande) ---
  24.20 C / 44.00 % em 2026-09-29 23:00:00
  Uma linha, uma consulta, ORDER BY dt_leitura DESC LIMIT 1.

--- ultimas 24 horas, ponto a ponto ---
  linhas: 24 | min 22.30 | max 34.30 | media 28.2
  24 pontos no grafico. Pico em 14:00h com 34.30 C;
  vale em 02:00h com 22.30 C.
  Com 24 pontos voce ve a subida da manha e o pico da tarde.

--- 7 dias, agrupando por hora ---
  168 grupos, um por hora de dado
  hora mais quente: 2026-09-23 14:00 com media 34.3 C

--- 7 dias, agrupando por dia ---
  2026-09-23  n=24  media 28.2  22.30 a 34.30 C
  2026-09-24  n=24  media 28.2  22.30 a 34.30 C
  2026-09-25  n=24  media 28.2  22.30 a 34.30 C
  2026-09-26  n=24  media 28.2  22.30 a 34.30 C
  2026-09-27  n=24  media 28.2  22.30 a 34.30 C
  2026-09-28  n=24  media 28.2  22.30 a 34.30 C
  2026-09-29  n=24  media 28.2  22.30 a 34.30 C

--- escolher periodo e granularidade: o que cada escolha custa ---
  o banco tem 168 linhas de dado (7 dias).

  periodo  | agrupar por | pontos | o que o painel mostra
  ---------+-------------+--------+-----------------------------
  24 h     | sem agrupar  |   24   | a subida da manha e o pico
  24 h     | por hora     |   24   | quase igual, resposta menor
   7 d     | por hora     |  168   | a semana toda, hora a hora
   7 d     | por dia      |    7   | so a media de cada dia
   30 d    | por hora     |  720   | 720 pontos: pesado no navegador
   30 d    | por dia      |   30   | o painel do professor

  Escolher o periodo e escolher a granularidade sao a MESMA decisao.
  A pergunta nao e "quanto existe no banco", e "quanto o painel precisa".
  Guardar o detalhe todo e o erro: o banco guarda, e a consulta nao devolve.

--- media, minima e maxima no mesmo ponto ---
  2026-09-23  media 28.2  min 22.30  max 34.30
  2026-09-24  media 28.2  min 22.30  max 34.30
  2026-09-25  media 28.2  min 22.30  max 34.30
  2026-09-26  media 28.2  min 22.30  max 34.30
  2026-09-27  media 28.2  min 22.30  max 34.30
  2026-09-28  media 28.2  min 22.30  max 34.30
  2026-09-29  media 28.2  min 22.30  max 34.30

  Com so a media, quatro dias seguidos aparecem com 24,4 e o grafico
  fica reto. O par min/max mostra que a noite cai para
  22.30 C e o dia sobe a 34.30 C. A variacao esta no par,
  e e ele que o painel precisa para desenhar a faixa.

O professor aponta quatro coisas nessa saída:

  • linhas: 24 | min 22.30 | max 34.30 e Pico em 14:00h: 24 pontos mostram a forma do dia. Com 24 pontos dá para dizer "às duas da tarde estava com 34 graus", e essa frase é a que responde à pergunta do professor. Com 7 pontos, do mesmo dado, só dá para dizer "o dia 23 teve média 28,2", e a pergunta sobre as duas da tarde fica sem resposta.
  • 168 grupos contra 7 linhas: a mesma semana, a mesma tabela, o mesmo dado. A diferença é uma palavra, GROUP BY, e a diferença de resultado é um gráfico com 168 pontos contra um com sete.
  • Sete dias com media 28.2 idêntica: essa repetição é proposital na série semeada, e o professor usa para mostrar a armadilha. Um painel que mostra só média desenha uma reta e afirma que a sala passou a semana inteira a 28 graus.
  • min 22.30 e max 34.30 ao lado da média: doze graus de amplitude escondidos atrás de uma média igual nos sete dias. A faixa do gráfico é o que dá a informação.

O sketch da placa, que é o firmware do projeto — e que fica gravado até o fim do trimestre:

// dia 13, aula 1: o firmware do projeto — historico.
//
// Este e o sketch que fica gravado na placa ate o fim do trimestre. Nao e
// exemplo de sala: e o produto. Ele le o sensor, monta o JSON, manda para
// o Node e, quando o Node responde, guarda o id que o banco deu.
//
// O que o Node faz com esse dado NAO esta aqui: a placa nao decide periodo,
// nao agrupa por hora, nao monta grafico. Ela so entrega a leitura. Toda a
// regra do historico vive no servidor, e e por isso que o dia 12 conseguiu
// testar a regra sem placa nenhuma.

#include <Arduino.h>
#include <DHT.h>
#include <HTTPClient.h>
#include <WiFi.h>

// ===========================================================================
// CONFIGURACAO — dia 11 aula 1. Nada disto vai no git: os valores chegam
// pelo `secrets.h` da pasta, que o .gitignore protege.
// O SSID abaixo e FICTICIO e e o nome da rede da escola.
// ===========================================================================
const char* SSID        = "rede-da-escola";
const char* SENHA_WIFI  = "senha-ficticia-da-bancada";   // FICTICIA, vai no secrets.h
const char* URL_API     = "http://192.168.0.10:3000/api/leitura";
const char* CHAVE_API   = "chave-ficticia-da-bancada";

const int PIN_DHT = 4;
const uint8_t TIPO_SENSOR = DHT11;

// 10 s entre envios. Abaixo disso o banco enche de linha repetida sem
// informacao nova: e o dia 7 aula 1 que mediu isso.
const unsigned long INTERVALO_ENVIO_MS = 10000;

// Limite do alerta, em graus. Vai no servidor tambem (dia 13 aula 2), e os
// dois lados tem de concordar: se a placa avisar e o painel nao, a turma
// acredita na placa.
const float LIMITE_ALERTA_C = 32.0f;

unsigned long ultimoEnvio = 0;
unsigned long leiturasEnviadas = 0;
unsigned long leiturasPerdidas = 0;

DHT sensor(PIN_DHT, TIPO_SENSOR);

// Monta o JSON. Separar em funcao propria (dia 10 aula 1) e o que permite
// reusar este mesmo codigo no painel do dia 13 aula 2 sem reescrever.
String montarPayload(float temperatura_c, float umidade_pct, unsigned long seq) {
  char buffer[160];
  if (isnan(temperatura_c) || isnan(umidade_pct)) {
    return "";   // sinal unico de "nao leu": o Node devolve 422 e o log explica
  }
  snprintf(buffer, sizeof(buffer),
           "{\"id_dispositivo\":\"estacao-01\",\"temperatura_c\":%.1f,"
           "\"umidade_pct\":%.0f,\"sequencia\":%lu}",
           temperatura_c, umidade_pct, seq);
  return String(buffer);
}

// Envia e devolve o status HTTP. O `?` da resposta vem do dia 3: e o que
// separa "gravei" de "deu errado", e sem isso o aluno so sabe que o
// servidor estava vivo.
int enviarLeitura(const String& payload) {
  if (payload.length() == 0) return 0;   // leitura invalida: nem tenta

  HTTPClient http;
  http.setTimeout(8000);
  http.begin(URL_API);
  http.addHeader("Content-Type", "application/json");
  http.addHeader("X-Chave", CHAVE_API);   // o muro 1 do dia 9, lado da placa

  int status = http.POST(payload);
  http.end();
  return status;
}

void mostrarLeitura(float t, float u, int status) {
  Serial.print("leitura: ");
  Serial.print(t, 1);
  Serial.print(" C / ");
  Serial.print(u, 0);
  Serial.print(" %  ->  HTTP ");
  Serial.println(status);

  if (status == 201) {
    Serial.println("          gravada no historico");
  } else if (status == 0) {
    Serial.println("          nao houve resposta (timeout ou sem rede)");
    Serial.println("          esta leitura NAO esta no historico");
  } else if (status == 401) {
    Serial.println("          o servidor recusou a chave do projeto");
  } else if (status == 422) {
    Serial.println("          o servidor recusou o dado (fora de faixa)");
  }
}

void setup() {
  Serial.begin(115200);
  Serial.println();
  Serial.println("estacao-01 — projeto do trimestre");
  Serial.println("=================================");
  Serial.print("servidor: ");
  Serial.println(URL_API);
  Serial.print("intervalo: ");
  Serial.print(INTERVALO_ENVIO_MS / 1000UL);
  Serial.println(" s");

  Serial.println();
  Serial.println("conectando no WiFi...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(SSID, SENHA_WIFI);

  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000UL) {
    Serial.print(".");
    delay(500);
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println();
    Serial.print("conectado, ip=");
    Serial.println(WiFi.localIP());
  } else {
    // Falhar cedo e dizer por que. A aula de log do dia 8 e o que o
    // professor pede aqui: o painel vai mostrar "sem leitura" e ninguem
    // vai saber por que sem esta linha.
    Serial.println();
    Serial.println("FALHA: nao Associou em 15 s. Historico para de crescer.");
    Serial.println("Causa provavel: senha errada no secrets.h ou roteador fora.");
    Serial.println("A placa continua viva: o log continua rolando.");
  }

  sensor.begin();
  Serial.println();
  Serial.println("--- historico em andamento ---");
  ultimoEnvio = millis() - INTERVALO_ENVIO_MS;   // envia a primeira logo depois
}

void loop() {
  if (millis() - ultimoEnvio < INTERVALO_ENVIO_MS) {
    return;   // a placa faz outras coisas enquanto espera (dia 3 aula 2)
  }
  ultimoEnvio = millis();

  float t = sensor.readTemperature();
  float u = sensor.readHumidity();

  String payload = montarPayload(t, u, leiturasEnviadas + leiturasPerdidas + 1);
  int status = enviarLeitura(payload);

  if (status == 201) {
    leiturasEnviadas++;
  } else {
    leiturasPerdidas++;
  }
  mostrarLeitura(t, u, status);

  Serial.print("          enviadas=");
  Serial.print(leiturasEnviadas);
  Serial.print(" perdidas=");
  Serial.print(leiturasPerdidas);
  Serial.println();
}

Por que assim e não de outro jeito. O montarPayload devolve String vazia quando o sensor devolveu NaN, e essa string vazia é o sinal único de "não leu". A alternativa seria devolver um JSON com "temperatura_c": null, que é mais honesto na forma e pior na prática: o JSON com null chega ao Node, o validador do dia 8 tem um caso novo para tratar, e a placa está gastando bytes para anunciar que não sabe nada. A string vazia não chega ao servidor, e a linha if (payload.length() == 0) return 0; no enviarLeitura impede o envio. O status zero que sai daí é o mesmo que o HTTPClient devolve em timeout, e a coincidência é proposital: "não saiu" e "não voltou" são o mesmo problema para quem lê o histórico, e o painel precisa distinguir um do outro só pela origem, não pelo número.

O snprintf com sizeof(buffer) é o printf com limite, e o professor volta ao dia 1 do 1o trimestre para mostrar por que o limite importa: snprintf garante o terminador mesmo quando o texto não cabe, truncando o conteúdo. Um sprintf sem limite, com o DHT11 devolvendo NaN formatado como texto grande, escreveria além dos 160 bytes e corromperia a memória vizinha.

A sequência é enviada como leiturasEnviadas + leiturasPerdidas + 1, e o professor explica a soma em voz alta: a placa conta todas as leituras que tentou enviar, não só as que deram certo. Se ela contasse só as enviadas, a sequência repetiria depois de uma falha, e o servidor não teria como dizer que a leitura 47 faltou. A soma é o que garante que a sequência é um contador sem buraco do ponto de vista da placa — com a ressalva de que o buraco real está do outro lado, no que a rede engoliu, e é por isso que o id do banco é a verdade final.

O while de conexão do WiFi tem trinta segundos de limite medidos em millis(), e não um delay fixo seguido de checagem. A razão é a mesma do millis() do dia 3 aula 2: delay(15000) significa "espere quinze segundos e depois veja", o que dá um tempo total de quinze segundos mais o tempo da conexão. O millis() mede o tempo real decorrido, e o limite é de dezessete e meio: a placa espera até dezessete e meio, não para no primeiro segundo se já estava conectada. O professor pede que a turma note que o limite generoso é para a placa não desistir de uma rede lenta da escola, e que a mensagem de falha seguinte diz a causa provável em vez de dizer "erro": senha errada no secrets.h ou roteador fora. É a aula de log do dia 8 aplicada ao Serial.

A mensagem de falha tem uma frase que o professor destaca: "A placa continua viva: o log continua rolando". É a diferença entre a placa que para e a placa que continua sem dado. Uma placa que desiste no primeiro erro de WiFi vira uma placa morta na bancada, e a diferença entre as duas aparece no dia 15, quando a placa passa a noite sem servidor: ela precisa continuar rodando e registrando que não conseguiu, em vez de travar e parecer desligada.

O return no começo do loop é a implementação do dia 3 aula 2, e ele é a razão de a placa não ficar presa esperando. O loop inteiro cabe em um instante, e o return devolve o controle ao sistema, que chama o loop de novo. O intervalo de dez segundos é controlado por comparação de millis(), não por delay(10000) no fim do loop — se fosse delay, a placa ficaria parada dez segundos e não responderia a nada.

Criterios de correcao

CritérioPontos
Requisito do projeto escrito em uma frase, com os verbos sublinhados e as consultas ao lado2 pontos
Consulta da última leitura escrita e rodada, com a linha copiada1 pontos
Tabela período por granularidade preenchida, com a granularidade escolhida e justificada3 pontos
As duas consultas de 7 dias (por hora e por dia) escritas, com os dois números de pontos2 pontos
Consulta com AVG, MIN e MAX escrita e rodada, com a comparação entre os dias2 pontos
Versão ancorada no dado escrita, e a comparação de contagem com a versão de NOW()1 pontos
Item 7: contagem de enviadas e perdidas da placa, e o que apareceu no banco1 pontos

Erros comuns

ErroComo apareceCorreção
Devolver o detalhe todo sempre"O gráfico da semana tem 168 pontos e trava o navegador""Escolher granularidade é escolher quanto o painel precisa, não quanto existe. 7 dias por dia são 7 pontos e respondem a pergunta do professor; por hora são 168 e não respondem nada a mais."
GROUP BY escrito depois de rodar"Rodei, vi que tinha 24 horas, e agrupei por hora""A granularidade é uma decisão, escrita antes de consultar. Se você rodar primeiro e olhar, você vai agrupar pelo que apareceu — que é exatamente o erro que o painel do dia 15 vai cobrar."
Só AVG no gráfico"Meu painel mostra 28,2 todo dia""A média esconde a variação: a noite cai para 22 e o dia sobe a 34. MIN e MAX no mesmo ponto é o que dá a faixa, e sem ela o gráfico é uma reta que mente."
NOW() como âncora do período"De manhã o gráfico está vazio""Ancore na última leitura, não no relógio. Se a placa ficou desligada a noite, NOW() devolve meia hora de dado; a âncora devolve as 24 horas e mostra quando parou, que é a informação que importa."
Escolher período e granularidade por hábito"Usei 7 dias por dia porque foi o exemplo""A pergunta é o que o painel do professor precisa responder. 'Como estava terça à tarde' precisa de granularidade de hora; 'a semana foi quente' precisa de granularidade de dia."
Placa decidindo o período"Passei o período pela query string do sketch""A placa entrega a leitura, e só. Se o período morasse no firmware, testar cada período exigiria bancada, e o teste do dia 12 não rodaria sem placa."
Contar só as leituras enviadas"A sequência voltou a 47 depois da falha""A sequência conta as tentativas, não os acertos: enviadas + perdidas + 1. Sem a soma, a sequência repete depois de uma falha e o servidor não consegue dizer que faltou uma."
Reciclar a estrutura do String a cada envio"A placa reinicia depois de algumas horas""O dia 5 do 1o trimestre mostrou o custo de alocar String em laço. Monte o payload num char de tamanho fixo com snprintf e devolva a String já pronta, uma vez por envio."
Sem índice na coluna de data"A consulta de 30 dias demora três segundos""Com KEY em dt_leitura, o WHERE sobre a data usa o índice. Sem ele, o banco lê a tabela inteira e agrupa — e o painel do dia 15 paga isso a cada atualização."
Perder a distinção entre "não saiu" e "não voltou""O painel diz que o dado foi recusado""Payload vazio e timeout dão o mesmo status zero de propósito: para o histórico, os dois significam ausência. Se você precisar distinguir, é a origem da falha, não o número do status."
Guardar só a média na tabela"Não consigo mostrar a variação depois""Se o banco guarda só a média, a granularidade de hora é impossível de reconstruir. Guarde a leitura; agregar é decisão da consulta, do dia 7 aula 1."

Desafio extra

Construa a rota que responde "como estava a sala terça às 14h", e que para isso precise juntar três decisões: o agrupar por hora do dia 7, o escolher período de sete dias, e a granularidade do gráfico de uma hora. Meça o tempo dessa rota com o banco de sete dias e com o banco de um mês, e anote os dois números no caderno. Depois escreva a mesma rota para "como foi a semana", com granularidade de dia, e meça de novo. A resposta que o professor espera: a mesma tabela responde às duas perguntas em tempos próximos, e o que muda não é o banco — é quantos pontos a consulta devolve.

>

A resolucao, compilada

// dia 13, aula 1: o firmware do projeto — historico.
//
// Este e o sketch que fica gravado na placa ate o fim do trimestre. Nao e
// exemplo de sala: e o produto. Ele le o sensor, monta o JSON, manda para
// o Node e, quando o Node responde, guarda o id que o banco deu.
//
// O que o Node faz com esse dado NAO esta aqui: a placa nao decide periodo,
// nao agrupa por hora, nao monta grafico. Ela so entrega a leitura. Toda a
// regra do historico vive no servidor, e e por isso que o dia 12 conseguiu
// testar a regra sem placa nenhuma.

#include <Arduino.h>
#include <DHT.h>
#include <HTTPClient.h>
#include <WiFi.h>

// ===========================================================================
// CONFIGURACAO — dia 11 aula 1. Nada disto vai no git: os valores chegam
// pelo `secrets.h` da pasta, que o .gitignore protege.
// O SSID abaixo e FICTICIO e e o nome da rede da escola.
// ===========================================================================
const char* SSID        = "rede-da-escola";
const char* SENHA_WIFI  = "senha-ficticia-da-bancada";   // FICTICIA, vai no secrets.h
const char* URL_API     = "http://192.168.0.10:3000/api/leitura";
const char* CHAVE_API   = "chave-ficticia-da-bancada";

const int PIN_DHT = 4;
const uint8_t TIPO_SENSOR = DHT11;

// 10 s entre envios. Abaixo disso o banco enche de linha repetida sem
// informação nova: e o dia 7 aula 1 que mediu isso.
const unsigned long INTERVALO_ENVIO_MS = 10000;

// Limite do alerta, em graus. Vai no servidor tambem (dia 13 aula 2), e os
// dois lados tem de concordar: se a placa avisar e o painel nao, a turma
// acredita na placa.
const float LIMITE_ALERTA_C = 32.0f;

unsigned long ultimoEnvio = 0;
unsigned long leiturasEnviadas = 0;
unsigned long leiturasPerdidas = 0;

DHT sensor(PIN_DHT, TIPO_SENSOR);

// Monta o JSON. Separar em funcao propria (dia 10 aula 1) e o que permite
// reusar este mesmo codigo no painel do dia 13 aula 2 sem reescrever.
String montarPayload(float temperatura_c, float umidade_pct, unsigned long seq) {
  char buffer[160];
  if (isnan(temperatura_c) || isnan(umidade_pct)) {
    return "";   // sinal unico de "nao leu": o Node devolve 422 e o log explica
  }
  snprintf(buffer, sizeof(buffer),
           "{\"id_dispositivo\":\"estacao-01\",\"temperatura_c\":%.1f,"
           "\"umidade_pct\":%.0f,\"sequencia\":%lu}",
           temperatura_c, umidade_pct, seq);
  return String(buffer);
}

// Envia e devolve o status HTTP. O `?` da resposta vem do dia 3: e o que
// separa "gravei" de "deu errado", e sem isso o aluno so sabe que o
// servidor estava vivo.
int enviarLeitura(const String& payload) {
  if (payload.length() == 0) return 0;   // leitura invalida: nem tenta

  HTTPClient http;
  http.setTimeout(8000);
  http.begin(URL_API);
  http.addHeader("Content-Type", "application/json");
  http.addHeader("X-Chave", CHAVE_API);   // o muro 1 do dia 9, lado da placa

  int status = http.POST(payload);
  http.end();
  return status;
}

void mostrarLeitura(float t, float u, int status) {
  Serial.print("leitura: ");
  Serial.print(t, 1);
  Serial.print(" C / ");
  Serial.print(u, 0);
  Serial.print(" %  ->  HTTP ");
  Serial.println(status);

  if (status == 201) {
    Serial.println("          gravada no historico");
  } else if (status == 0) {
    Serial.println("          nao houve resposta (timeout ou sem rede)");
    Serial.println("          esta leitura NAO esta no historico");
  } else if (status == 401) {
    Serial.println("          o servidor recusou a chave do projeto");
  } else if (status == 422) {
    Serial.println("          o servidor recusou o dado (fora de faixa)");
  }
}

void setup() {
  Serial.begin(115200);
  Serial.println();
  Serial.println("estacao-01 — projeto do trimestre");
  Serial.println("=================================");
  Serial.print("servidor: ");
  Serial.println(URL_API);
  Serial.print("intervalo: ");
  Serial.print(INTERVALO_ENVIO_MS / 1000UL);
  Serial.println(" s");

  Serial.println();
  Serial.println("conectando no WiFi...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(SSID, SENHA_WIFI);

  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000UL) {
    Serial.print(".");
    delay(500);
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println();
    Serial.print("conectado, ip=");
    Serial.println(WiFi.localIP());
  } else {
    // Falhar cedo e dizer por que. A aula de log do dia 8 e o que o
    // professor pede aqui: o painel vai mostrar "sem leitura" e ninguem
    // vai saber por que sem esta linha.
    Serial.println();
    Serial.println("FALHA: nao Associou em 15 s. Historico para de crescer.");
    Serial.println("Causa provavel: senha errada no secrets.h ou roteador fora.");
    Serial.println("A placa continua viva: o log continua rolando.");
  }

  sensor.begin();
  Serial.println();
  Serial.println("--- historico em andamento ---");
  ultimoEnvio = millis() - INTERVALO_ENVIO_MS;   // envia a primeira logo depois
}

void loop() {
  if (millis() - ultimoEnvio < INTERVALO_ENVIO_MS) {
    return;   // a placa faz outras coisas enquanto espera (dia 3 aula 2)
  }
  ultimoEnvio = millis();

  float t = sensor.readTemperature();
  float u = sensor.readHumidity();

  String payload = montarPayload(t, u, leiturasEnviadas + leiturasPerdidas + 1);
  int status = enviarLeitura(payload);

  if (status == 201) {
    leiturasEnviadas++;
  } else {
    leiturasPerdidas++;
  }
  mostrarLeitura(t, u, status);

  Serial.print("          enviadas=");
  Serial.print(leiturasEnviadas);
  Serial.print(" perdidas=");
  Serial.print(leiturasPerdidas);
  Serial.println();
}

Sem saída de compilação gravada. Rode python3 validar.py -t 2 dia13 aula1.

Aula 2 — Projeto: painel com leitura atual e alerta

Objetivos

  • Montar o painel com leitura atual, com o valor grande, a unidade e o status do envio na mesma tela, e saber dizer o que cada campo responde.
  • Escrever a regra do alerta como duas constantes separadas, uma para ligar e outra para desligar, e não como um número só.
  • Medir quantas vezes o alerta muda de estado numa série de 400 leituras, com um limiar e com dois, e comparar os dois números.
  • Aplicar a histerese a um segundo par de limites, o de umidade, e explicar em uma frase por que a faixa não é o mesmo número nos dois casos.
  • Escolher entre aviso no painel, destaque no gráfico e cor de alerta, e dizer qual dos três resolve o problema que a turma encontrou no item 4.

Material

  • 1 ESP32 DevKit V1 por dupla, com o cabo USB
  • 1 DHT11 por dupla, com resistor de pull-up de 4,7 k ohm entre o pino de dados e 3V3
  • 1 display SSD1306 de 128x64 com interface I2C, do dia 9
  • 1 computador com o projeto do dia 13 aula 1 rodando, com a tabela de histórico gravada
  • Folha de papel por dupla, dividida em duas colunas: uma para o alerta de temperatura e outra para o de umidade
  • Projetor, para o monitor serial da placa e o Serial do Node na mesma tela

Conceitos

O painel tem três respostas, e uma delas é um número

O painel com leitura atual é a parte do projeto que o professor olha de dentro da sala, a dois metros de distância. Isso decide o formato inteiro: o valor grande ocupa metade da tela, a unidade fica colada no número, e o resto da tela carrega o que o número não diz.

As três respostas do painel são o valor, o estado da comunicação e o aviso. No Serial elas viram três linhas separadas, e na tela viram três lugares diferentes:

RespostaPergunta que ela respondeOnde aparece
o valor grandequanto está agorameio da tela, com a unidade do lado
o status do envioesse número está no bancocanto inferior direito
o avisoalguém precisa agir sobre issorodapé, escrito por extenso

A diferença entre a segunda e a terceira linha é o que separa um painel de um número bonito. Um painel que mostra 27,4 C de uma leitura de meia hora atrás está mentindo, e mente com a maior legibilidade possível. Por isso o status do POST do dia 13 aula 1 aparece na tela: o -> 201 diz que o número da tela também está no banco, e o -> falha diz que o painel está mostrando um número que existe apenas ali dentro.

A última leitura do banco continua sendo a consulta do dia 13 aula 1, ORDER BY dt_leitura DESC LIMIT 1. Nada muda nela. O que muda é que agora existe uma segunda fonte de verdade na frente dela — a placa — e as duas precisam concordar. Se a tela mostrar 27 e o banco mostrar 26, o professor pergunta qual das duas está certa, e a resposta é "a que tem o carimbo de hora", que é a da aula de amanhã.

Limiar é o número; o estado é o que lembra

O limiar é o número que separa os dois lados. O limite de temperatura desta aula é LIMITE_LIGAR_C = 32.0, o valor acima do qual o alerta entra, e ele sozinho produz esta regra:

desligado vira ligado    se  temperatura > 32,0
ligado vira desligado    se  temperatura < 32,0

O problema dessa regra é que ela tem uma terceira clause escondida: e se a temperatura for exatamente 32,0? Com > ela fica desligado, e com < ela fica ligado. As duas respostas são defensáveis, e a escolha muda o que acontece na leitura seguinte. Isso é o aviso que o professor dá na lousa antes de qualquer código: a regra que você escreve decide o caso do empate, e o caso do empate é justamente o caso que mais acontece, porque o sensor queima na sua frente.

O estado de alerta é a variável que guarda em qual dos dois lados a placa está. Ela precisa existir fora do loop, pelo mesmo motivo que o ambienteEscuro do dia 8 do primeiro trimestre existia fora: um estado que só existe dentro do laço é reavaliado do zero a cada volta, e não tem com o que comparar. O alerta ligado e desligado não são dois valores calculados, são dois valores lembrados, e a lembrança é a variável.

A distinção que a turma precisa levar é esta: o valor da temperatura é medido, o estado de alerta é decidido. O DHT11 não sabe que existe alerta. Ele devolve um número a cada leitura, e a placa faz a conta. Isso importa para o dia 15, quando alguém perguntar quem decide: a resposta é "o código, nos dois lados, com os mesmos números".

Histerese: a faixa que evita alerta oscilante

A histerese é a correção, e ela cabe numa frase: o alerta entra por um número e sai por outro. Entre os dois, o estado anterior manda. A faixa que separa os dois números é o que resolve, e o defeito que ela evita tem nome: alerta oscilante, o alerta que fica mudando de estado sozinho e faz a pessoa desligar o aviso.

desligado vira ligado    se  temperatura > 32,0
ligado vira desligado    se  temperatura < 30,0
entre 30,0 e 32,0        o estado nao muda: quem mandou, mandou

A faixa de dois graus é o que evita alerta oscilante, e o nome técnico do defeito é o mesmo. A turma vai ver o nome na lousa e vai entender por que o dia 12 aula 1 já tinha escrito um teste que hoje falha: com um limiar só, o teste da folga não tem como passar. A forma curta de evitar alerta oscilante é essa, e ela cabe em duas constantes.

A medição que o professor faz na lousa, e que qualquer dupla repete no caderno, usa uma série que o DHT11 produz de verdade quando a sala está parada em 32 graus. Ele escreve oito números no quadro e manda repetir cinquenta vezes:

32  33  32  31   32  33  32  31   ...  (oito por vez, cinquenta vezes)

São quatrocentas leituras. Agora as duas contas, e o professor manda contar os momentos em que o estado muda:

RegraMudanças em 400 leiturasPor quê
um limiar só, 32,0 nos dois sentidos200 mudançascada 33 liga e cada 31 desliga, e são dois cambios a cada quatro leituras
histerese, liga em 32,0 e desliga em 30,01 mudançao alerta liga no primeiro 33 e só desliga abaixo de 30,0, e a sala nunca chega lá

O número do meio é o que convence a turma, e ele é verificável em trinta segundos: 400 / 4 = 100 ciclos, 100 × 2 = 200. Não é estimativa nem ordem de grandeza. É a mesma conta feita com lápis no quadro, e qualquer aluno que refazer com os números que ele próprio escreveu chega no mesmo lugar.

Duzentas vezes é um aviso por dezoito segundos, se a placa ler a cada dez segundos. É exatamente o número que faz a pessoa desligar o alerta e nunca mais ligar: o aviso que muda de ideia todo dia não é um aviso, é um barulho. E o professor fecha a conta com a segunda linha da tabela: com a histerese, a sala está a dois graus do limite o dia inteiro e o aviso não muda de estado. Não é que a folga "suavize" o alerta; é que ela tira o estado do meio da lista de decisões.

O limite de umidade tem a mesma forma e uma folga diferente, e a turma escolhe o número. A umidade da sala varia menos que a temperatura, e o DHT11 erra mais nela — a folga de cinco pontos percentuais que serve para a temperatura é enorme para a umidade, e o professor pede a conta: quanto da faixa pode ser só ruído do sensor? A resposta esperada é que a folga tem que ser maior que o erro do sensor, senão a histerese existe no papel e o alerta oscilante continua no Serial.

Aviso no painel, destaque no gráfico e cor de alerta

Depois da faixa, a turma tem três lugares possíveis para o aviso, e a escolha é do projeto, não do professor.

O aviso no painel é texto. A palavra ALERTA escrita no rodapé da tela é o mais direto e o mais difícil de ignorar de longe, e é a que o sketch da resolução escreve. O custo é que some assim que o valor volta ao normal, e ninguém guarda a história de quantas vezes aquilo aconteceu.

O destaque no gráfico é o segundo: a forma de destacar no gráfico é pintar de outra cor o ponto que passou do limite, ou desenhar uma faixa horizontal no lugar de 32 graus. É a resposta que sobrevive ao dia seguinte, porque o gráfico continua lá de manhã e o texto não. É também a que exige a segunda consulta da aula 1, o MAX do grupo, porque o pico é o que marca a cor.

A cor de alerta é a terceira e a mais barata de implementar, e por isso a mais subestimada. Um painel com a mesma informação, a mesma fonte e o mesmo intervalo, que muda só a cor do número, comunica o estado antes de a pessoa terminar de ler o número. O professor não escreve isso como receita: escreve que a escolha de cor é a decisão de projeto que mais aparece na segunda olhada e menos aparece na segunda-feira.

A notificação é a quarta possibilidade, e ela está fora do escopo desta aula por um motivo técnico, não por dificuldade: notificar exige saber quando ocorreu a transição, e o alerta piscando não é transição, é ruído. A notificação de verdade é enviada no instante em que o estado muda, e a histerese é o que faz esse instante existir — uma transição a cada dezoito segundos não é notificável. A turma escreve isso no caderno e a aula de notificação do próximo trimestre pega o assunto com a base já pronta.

Atividade

Montagem:

  • DHT11 no GPIO4, com pull-up de 4,7 k ohm.
  • Display SSD1306 de 128x64 no I2C: SDA no GPIO21 e SCL no GPIO22.
  • Servidor Node do dia 13 aula 1 rodando, com a tabela de histórico do dia 12.
  • Cabo USB conectado, monitor serial em 115200.
  1. Escreva no caderno os três campos do painel com leitura atual — valor, status do envio, aviso — e ao lado de cada um escreva a pergunta que ele responde. Se um dos três campos não responde a nenhuma pergunta, ele sai do painel: escreva isso também.
  2. Escreva a regra do alerta com um limiar só: o alerta liga acima de um número e desliga abaixo do mesmo número. Grave e conte, em vinte leituras da sua bancada, quantas vezes o estado mudou. O DHT11 parado na sua mão basta.
  3. Agora escreva a regra com dois limiares: um para ligar e outro para desligar, com dois graus de diferença. Grave e repita a contagem. Escreva os dois números lado a lado.
  4. Refaça a conta da lousa no seu caderno: escreva oito números de uma sala parada em 32 graus, repita cinquenta vezes e conte as mudanças de estado com um limiar só. Depois conte com os dois limiares. Os dois números que você escreveu batem com os do quadro?
  5. Escreva a mesma regra com histerese para o limite de umidade, escolhendo a folga. Escreva em uma frase por que a sua folga de umidade não pode ser a mesma de temperatura.
  6. Escolha uma das três saídas do aviso — texto no painel, destaque no gráfico ou cor de alerta — e justifique em uma linha. Se você escolher cor, escreva qual cor em qual estado, e se escolher destaque, escreva o que no gráfico muda.
  7. Grave o sketch da resolução e deixe rodando por três minutos. Cole no caderno: quantas vezes o Serial imprimiu a linha de alerta, e o que apareceu na tela quando o DHT11 devolveu NaN.
  8. Em uma frase: por que o aviso não pode ser enviado a cada leitura acima do limite, e sim só no instante em que o estado muda?

Nota: 12 pontos. Critério de fim: as duas regras — um limiar e dois limiares — escritas e gravadas, com a contagem de 400 leituras feita no caderno e os dois números lado a lado.

Resolucao

O sketch do painel é o firmware do produto, e é o mesmo arquivo do dia 13 aula 1 com três peças a mais: a tela, a regra do alerta em dois limiares e a tabela que conta a histerese sem sensor nenhum.

// dia 13, aula 2: o painel — leitura atual e alerta.
//
// Este sketch e o firmware do painel: sensor, display e WiFi, que e o
// produto do trimestre. O que muda em relacao ao dia 13 aula 1 e a
// histerese do alerta.
//
// HISTERESE: um alerta que liga em 32 C e desliga em 32 C fica piscando a
// cada leitura perto do limite. Com uma faixa, liga em 32 e so desliga
// abaixo de 30. E o problema classico de qualquer painel de sala, e o
// aluno o ve acontecer na propria bancada antes de o professor explicar.

#include <Arduino.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>
#include <HTTPClient.h>
#include <WiFi.h>

const int PIN_DHT = 4;
const uint8_t TIPO_SENSOR = DHT11;

// Display I2C: o endereco 0x3C e o do modulo SSD1306 de 128x64 mais comum.
// O 0x3D e do outro modelo. Se a tela ficar preta, e o endereco — nao e o
// codigo. O dia 9 do 1o trimestre ja tinha mostrado isso.
const int PIN_SDA = 21;
const int PIN_SCL = 22;
const int LARGURA = 128;
const int ALTURA = 64;

Adafruit_SSD1306 tela(LARGURA, ALTURA, &Wire, -1);
DHT sensor(PIN_DHT, TIPO_SENSOR);

// ---------------------------------------------------------------------------
// A REGRA DO ALERTA, COM HISTERESE
//
//   liga o alerta    quando passa de LIGA_C
//   desliga o alerta quando cai abaixo de DESLIGA_C
//   entre os dois     o estado NAO muda: quem mandou, mandou
//
// Os dois numeros diferentes sao o ponto. Com um so, a leitura 32,0 liga
// e a leitura 31,9 desliga, e o painel pisca duas vezes por minuto.
// ---------------------------------------------------------------------------
const float LIMITE_LIGAR_C   = 32.0f;
const float LIMITE_DESLIGAR_C = 30.0f;   // 2 graus de folga

// Estado do alerta. Vive AQUI, no firmware, e o dia 13 aula 1 mostrou que
// a mesma regra roda no Node. Se as duas divergirem, a turma acredita na
// tela e o painel mente.
bool alertaAtivo = false;

// A funcao pura da histerese: mesma temperatura, mesmo resultado, sempre.
// E o que o teste do dia 12 consegue cobrir sem placa.
bool proximoEstadoAlerta(float temperatura_c, bool estadoAtual) {
  if (estadoAtual) return temperatura_c >= LIMITE_DESLIGAR_C;   // ja ligado, so sai se cair
  return temperatura_c > LIMITE_LIGAR_C;                        // desligado, entra se passar
}

void mostrarLeitura(float t, float u, int status) {
  tela.clearDisplay();

  tela.setTextSize(1);
  tela.setCursor(0, 0);
  tela.print("estacao-01");
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("rede ok, ip=");
    Serial.println(WiFi.localIP());
  } else {
    tela.print("  (sem rede)");
  }
  tela.println();

  // O valor grande: e o que o aluno ve da mesa de dentro da sala.
  tela.setTextSize(2);
  tela.setCursor(0, 18);
  if (isnan(t)) {
    Serial.println("DHT11 sem resposta: o painel mostra TRAVA, e nao 0");
    tela.print("TRAVA");
  } else {
    Serial.print("leitura atual: ");
    Serial.print(t, 1);
    Serial.println(" C");
    tela.print(t, 1);
    tela.print(" C");
  }

  tela.setTextSize(1);
  tela.setCursor(0, 42);
  Serial.print("umidade: ");
  Serial.print(u, 0);
  Serial.println(" %");
  tela.print("U ");
  tela.print(u, 0);
  tela.print(" %");

  // Status do envio: o painel mostra tambem se o dado saiu da placa. Sem
  // isso, o painel mostra um numero bonito de uma leitura de meia hora.
  tela.setCursor(64, 42);
  if (status == 201) {
    Serial.println("  envio: 201 gravado");
    tela.print("-> 201");
  } else if (status == 0) {
    Serial.println("  envio: falhou, leitura nao esta no historico");
    tela.print("-> falha");
  } else {
    Serial.print("  envio: recusado, HTTP ");
    Serial.println(status);
    tela.print("-> erro");
  }

  if (alertaAtivo) {
    Serial.println("  ALERTA LIGADO (acima de 32 C)");
    tela.setTextSize(1);
    tela.setCursor(0, 55);
    tela.print("ALERTA");
  }

  tela.display();
}

// A histerese, na pratica: a temperatura sobe de 30 para 34, depois desce
// de 34 para 30. O painel liga o alerta e so desliga 2 graus abaixo.
void mostrarHisterese() {
  Serial.println();
  Serial.println("--- histerese na pratica, sem sensor ---");
  Serial.println("temperatura | estado | mudou?");
  Serial.println("------------+--------+-------");

  const float serie[] = {29.0f, 31.0f, 32.5f, 34.0f, 31.0f, 30.5f, 29.5f};
  const int n = sizeof(serie) / sizeof(serie[0]);
  bool estado = false;
  int mudancas = 0;

  for (int i = 0; i < n; i++) {
    bool novo = proximoEstadoAlerta(serie[i], estado);
    bool mudou = novo != estado;
    if (mudou) mudancas++;
    Serial.print("    ");
    Serial.print(serie[i], 1);
    Serial.print("    | ");
    Serial.print(novo ? "LIGADO  " : "deslig.");
    Serial.print(" | ");
    Serial.println(mudou ? "MUDOU" : "-");
    estado = novo;
  }

  Serial.println();
  Serial.print("mudancas de estado: ");
  Serial.println(mudancas);
  Serial.println("Com um so limite (liga e desliga em 32,0), seriam 4: o painel");
  Serial.println("piscaria em cada leitura perto do limite. Com folga de 2 graus,");
  Serial.println("sao 2: liga uma vez, desliga uma vez.");
}

void setup() {
  Serial.begin(115200);
  Serial.println();
  Serial.println("dia 13 aula 2 — painel com leitura atual e alerta");
  Serial.println("===================================================");

  Serial.print("display: ");
  if (!tela.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("nao achou o display em 0x3C.");
    Serial.println("Se a tela ficar preta: e o endereco (0x3D), nao e o codigo.");
    Serial.println("O monitor serial continua funcionando: e o instrumento de aula.");
  } else {
    Serial.println("SSD1306 128x64 em 0x3C, ok");
  }
  sensor.begin();

  tela.clearDisplay();
  tela.setTextSize(1);
  tela.setCursor(0, 0);
  tela.println("estacao-01");
  tela.println("ligando...");
  tela.display();

  Serial.println();
  Serial.println("--- leitura atual ---");
  float t = sensor.readTemperature();
  float u = sensor.readHumidity();
  int status = 201;   // o POST real e do dia 13 aula 1
  mostrarLeitura(t, u, status);

  if (!isnan(t)) {
    bool novo = proximoEstadoAlerta(t, alertaAtivo);
    if (novo != alertaAtivo) {
      Serial.print("  alerta mudou para ");
      Serial.println(novo ? "LIGADO" : "desligado");
    }
    alertaAtivo = novo;
  }

  mostrarHisterese();

  Serial.println();
  Serial.println("--- a regra do alerta, do lado do Node ---");
  Serial.println("O painel mostra o alerta com a regra do firmware. O banco");
  Serial.println("grava com a regra do service. Se as duas usarem limites");
  Serial.println("diferentes, o aluno ve o alerta na tela e o histoico sem");
  Serial.println("nada. Por isso LIGAR_C e DESLIGAR_C sao os mesmos numeros");
  Serial.println("nos dois lados — e o dia 11 aula 1 que impede que voltem");
  Serial.println("a divergir por causa de um valor hardcoded.");
}

void loop() {
  Serial.println();
  Serial.println("--- nova rodada ---");
  setup();
  delay(10000);
}

A função proximoEstadoAlerta é a aula inteira, e ela cabe em duas linhas. O parâmetro estadoAtual entra e o estado novo sai, e a função não toca em nada mais: não lê sensor, não escreve na tela, não guarda nada. É uma função pura, e é por isso que o teste do dia 12 roda no computador, sem placa, sem DHT11 e sem display. A turma precisa entender que a regra do alerta é a única parte do painel que dá para testar sem bancada, e que o resto do painel não dá.

Os dois operadores são diferentes de propósito e o professor escreve isso na lousa: para entrar o alerta usa > — a leitura de exatamente 32,0 não liga —, e para sair usa >= — a leitura de exatamente 30,0 não desliga. A faixa de 30,0 a 32,0 é fechada nas duas pontas, e o estado anterior manda nela inteira. Um aluno que escrever >= nos dois lados ainda tem histerese e ainda funciona; um aluno que escrever <= no lugar de < também. Nenhum dos dois está errado, e o professor explica que a escolha do operador é o que decide o caso do empate, que é o caso que mais acontece com sensor real.

O nome da constante mudou em relação ao dia 13 aula 1, e a mudança é a aula. Lá estava LIMITE_ALERTA_C, um número só. Aqui são duas, LIMITE_LIGAR_C e LIMITE_DESLIGAR_C, e a segunda existe só por causa do > e do < do proximoEstadoAlerta. A mesma ideia vale para o limite de umidade: a dupla escreve LIMITE_LIGAR_PCT e LIMITE_DESLIGAR_PCT, e a segunda tem que estar a cinco pontos do primeiro, não a dois. O critério de correção olha para isso.

A série de sete pontos do mostrarHisterese é uma demonstração do mecanismo, e o professor é explícito sobre isso com a turma, porque o número que aparece no monitor não é a prova de nada. Aqueles sete valores vão de 29 a 34 e passam longe do limite, então a regra com um limiar e a regra com dois dão o mesmo número de mudanças: duas. A tabela serve para a turma ver a linha MUDOU e entender que o estado só muda quando a leitura cruza o número, e para ver que a faixa de 30,0 a 32,0 aparece na tabela como as três últimas linhas sem nenhuma mudança. O Serial fecha dizendo que com um limiar só seriam quatro, e esse quatro não confere com a contagem feita à mão; o professor não lê essa linha como argumento. A prova é a contagem de 400 do quadro, que dá 200 contra 1, e é a que o professor manda refazer no caderno de cada dupla.

O if (!isnan(t)) antes de mexer no estado do alerta é uma proteção que vale uma explicação. Se o DHT11 devolve NaN, a comparação NaN > 32.0 é falsa no C++, e NaN >= 30.0 também. Sem esse if, uma leitura quebrada desligaria o alerta — a placa que está sem sensor cancelaria o aviso, e a sala continuaria quente. Na tela o efeito é o mesmo do Serial: a palavra TRAVA no lugar do número, e nunca 0. Um painel que mostra zero graus em vez de TRAVA é um painel que mente com a maior legibilidade possível, que é a mesma razão do payload vazio do dia 13 aula 1.

O status que chega em mostrarLeitura vem do POST real do dia 13 aula 1, e nesta versão de sala ele entra como 201 fixo, com o comentário dizendo isso. A escolha é deliberada: o professor quer a turma olhando para a regra do alerta, não para a rede da escola. A conversão dos três casos — 201 gravado, 0 falhou, qualquer outro recusado — é a mesma do dia 11 aula 2, e ela reaparece aqui porque o painel é o primeiro consumidor de verdade do status.

A SSD1306_SWITCHCAPVCC e o 0x3C são a segunda falha clássica do I2C, e a mensagem de erro do sketch é escrita para o professor e não para o aluno: se a tela ficar preta, o código não está errado, o endereço está. O -1 no construtor do Adafruit_SSD1306 diz que o pino de reset é gerenciado pela biblioteca, e é o valor do dia 9.

O loop que chama setup é a decisão que mais chama atenção nesta versão de sala, e o professor anuncia antes de mostrar: este sketch recomeça de propósito a cada dez segundos para que o Serial tenha o que mostrar, e o custo é que o tela.begin e o sensor.begin são reexecutados a cada rodada, o que é desperdício e produz a mensagem estacao-01 / ligando... piscando na tela. Na versão que fica gravada na placa, o loop é o do dia 13 aula 1: uma leitura, um POST, um millis, e a tela é redesenhada dentro do mostrarLeitura. A diferença entre as duas versões é a diferença entre o script de aula e o firmware, e o professor usa essa diferença para fechar o trimestre: a partir de amanhã a placa vai dormir de verdade, e o que faz isso é o esp_deep_sleep.

Criterios de correcao

CritérioPontos
Três campos do painel nomeados, cada um com a pergunta que ele responde ao lado2 pontos
Regra com um limiar só, gravada, com a contagem de mudanças de estado da bancada2 pontos
Regra com dois limiares gravada, com LIMITE_LIGAR_C e LIMITE_DESLIGAR_C como constantes separadas no topo3 pontos
Contagem de 400 leituras feita no caderno, com os dois números escritos lado a lado2 pontos
Regra de umidade com folga própria, e a justificativa escrita em uma frase1 pontos
Escolha entre texto, destaque no gráfico ou cor de alerta, com a justificativa1 pontos
Sketch gravado, rodando, com a linha de estado do alerta no Serial1 pontos

Erros comuns

ErroComo apareceCorrecao
Dois limiares que não existem"Eu escrevi 32 e 30 e o alerta continua piscando""O segundo número só vale se o if também mudar. Se os dois lados comparam com o mesmo LIMITE_LIGAR_C, a folga está escrita e não é usada."
Reescrever o limiar no meio da função"Eu troco o número direto no Serial.println para testar""Os dois limiares são const no topo. O teste muda a série, nunca a constante — senão você mede uma regra diferente a cada rodada."
Comparar com =="Usei temperatura == 32.0 para acertar o empate""Com sensor real o empate não existe: 32,0 vem com ruído junto. O que decide o empate é o operador, e ele escolhe de que lado o limite cai."
NaN desligando o alerta"O aviso sumiu sozinho e a sala continua quente""Em C++, NaN > 32.0 é falso: a comparação toda dá falso e o estado vira desligado. Teste isnan antes de mexer no alerta, e mostre TRAVA na tela."
Guardar o estado dentro do loop"A histerese está certa no papel e o alerta pisca assim mesmo""O alertaAtivo precisa estar fora do loop, como arquivo. Dentro dele, ele nasce false a cada volta e não tem com o que comparar."
Mesma folga para os dois Grandezas"A umidade também ficou com 2""A folga tem que ser maior que o erro do sensor naquele grandeza. Para umidade o DHT11 erra mais: cinco pontos percentuais é o mínimo razoável, e o aluno escreve a conta."
Escolher o aviso sem justificar"Pintei de vermelho e pronto""Escreva qual dos três você escolheu e o que muda para quem olha de dois metros. Cor muda o que se vê; destaque no gráfico muda o que fica guardado."
Reexecutar o setup na versão gravada"A tela fica piscando 'ligando' e o sensor demora""O loop que chama setup é o script de aula. Na versão do produto o loop é o do dia 13 aula 1, e o redesenho da tela acontece dentro do mostrarLeitura."
Tela preta e o aluno caça bug no GFX"Reescrevi a biblioteca e a tela continua preta""Tela preta em SSD1306 é endereço: 0x3C e 0x3D são os dois modelos. O Serial continua funcionando, e ele é o instrumento de aula mesmo com a tela morta."
Aviso em vez de estado"O alerta é enviado toda vez que passa de 32""notificação se manda no instante da transição, não acima do limite. Com a histerese a transição existe; sem ela você manda duzentas notificações por dia e ninguém lê nenhuma."
Um limiar no firmware e outro no servidor"Na tela aparece alerta e o banco não marca nada""Os dois lados usam as mesmas duas constantes. É o .env do dia 11 aula 1 que impede a divergência, e é por isso que ele existe."
Ignorar o status no painel"O painel mostra 27,4 C de uma leitura que nunca chegou ao banco""O painel com leitura atual tem três campos, e o do meio é o status do POST. Sem ele o painel é um número bonito sem origem."

Desafio extra

Escreva a notificação que só existe: uma função que devolve true uma única vez por transição, e devolve false nas leituras seguintes enquanto o estado não muda. Grave na placa e conte, na série de 400, quantas vezes ela devolveria true com a histerese e quantas devolveria com um limiar só. Depois pinte o gráfico do painel com a cor de alerta nos pontos acima do limite, usando a consulta de MAX por hora da aula 1, e escreva em uma linha o que o destaque no gráfico responde que a cor do número não responde.

>

A resolucao, compilada

// dia 13, aula 2: o painel — leitura atual e alerta.
//
// Este sketch e o firmware do painel: sensor, display e WiFi, que e o
// produto do trimestre. O que muda em relacao ao dia 13 aula 1 e a
// histerese do alerta.
//
// HISTERESE: um alerta que liga em 32 C e desliga em 32 C fica piscando a
// cada leitura perto do limite. Com uma faixa, liga em 32 e so desliga
// abaixo de 30. E o problema classico de qualquer painel de sala, e o
// aluno o ve acontecer na propria bancada antes de o professor explicar.

#include <Arduino.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>
#include <HTTPClient.h>
#include <WiFi.h>

const int PIN_DHT = 4;
const uint8_t TIPO_SENSOR = DHT11;

// Display I2C: o endereco 0x3C e o do modulo SSD1306 de 128x64 mais comum.
// O 0x3D e do outro modelo. Se a tela ficar preta, e o endereco — nao e o
// codigo. O dia 9 do 1o trimestre ja tinha mostrado isso.
const int PIN_SDA = 21;
const int PIN_SCL = 22;
const int LARGURA = 128;
const int ALTURA = 64;

Adafruit_SSD1306 tela(LARGURA, ALTURA, &Wire, -1);
DHT sensor(PIN_DHT, TIPO_SENSOR);

// ---------------------------------------------------------------------------
// A REGRA DO ALERTA, COM HISTERESE
//
//   liga o alerta    quando passa de LIGA_C
//   desliga o alerta quando cai abaixo de DESLIGA_C
//   entre os dois     o estado NAO muda: quem mandou, mandou
//
// Os dois numeros diferentes sao o ponto. Com um so, a leitura 32,0 liga
// e a leitura 31,9 desliga, e o painel pisca duas vezes por minuto.
// ---------------------------------------------------------------------------
const float LIMITE_LIGAR_C   = 32.0f;
const float LIMITE_DESLIGAR_C = 30.0f;   // 2 graus de folga

// Estado do alerta. Vive AQUI, no firmware, e o dia 13 aula 1 mostrou que
// a mesma regra roda no Node. Se as duas divergirem, a turma acredita na
// tela e o painel mente.
bool alertaAtivo = false;

// A funcao pura da histerese: mesma temperatura, mesmo resultado, sempre.
// E o que o teste do dia 12 consegue cobrir sem placa.
bool proximoEstadoAlerta(float temperatura_c, bool estadoAtual) {
  if (estadoAtual) return temperatura_c >= LIMITE_DESLIGAR_C;   // ja ligado, so sai se cair
  return temperatura_c > LIMITE_LIGAR_C;                        // desligado, entra se passar
}

void mostrarLeitura(float t, float u, int status) {
  tela.clearDisplay();

  tela.setTextSize(1);
  tela.setCursor(0, 0);
  tela.print("estacao-01");
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("rede ok, ip=");
    Serial.println(WiFi.localIP());
  } else {
    tela.print("  (sem rede)");
  }
  tela.println();

  // O valor grande: e o que o aluno ve da mesa de dentro da sala.
  tela.setTextSize(2);
  tela.setCursor(0, 18);
  if (isnan(t)) {
    Serial.println("DHT11 sem resposta: o painel mostra TRAVA, e nao 0");
    tela.print("TRAVA");
  } else {
    Serial.print("leitura atual: ");
    Serial.print(t, 1);
    Serial.println(" C");
    tela.print(t, 1);
    tela.print(" C");
  }

  tela.setTextSize(1);
  tela.setCursor(0, 42);
  Serial.print("umidade: ");
  Serial.print(u, 0);
  Serial.println(" %");
  tela.print("U ");
  tela.print(u, 0);
  tela.print(" %");

  // Status do envio: o painel mostra tambem se o dado saiu da placa. Sem
  // isso, o painel mostra um numero bonito de uma leitura de meia hora.
  tela.setCursor(64, 42);
  if (status == 201) {
    Serial.println("  envio: 201 gravado");
    tela.print("-> 201");
  } else if (status == 0) {
    Serial.println("  envio: falhou, leitura nao esta no historico");
    tela.print("-> falha");
  } else {
    Serial.print("  envio: recusado, HTTP ");
    Serial.println(status);
    tela.print("-> erro");
  }

  if (alertaAtivo) {
    Serial.println("  ALERTA LIGADO (acima de 32 C)");
    tela.setTextSize(1);
    tela.setCursor(0, 55);
    tela.print("ALERTA");
  }

  tela.display();
}

// A histerese, na pratica: a temperatura sobe de 30 para 34, depois desce
// de 34 para 30. O painel liga o alerta e so desliga 2 graus abaixo.
void mostrarHisterese() {
  Serial.println();
  Serial.println("--- histerese na pratica, sem sensor ---");
  Serial.println("temperatura | estado | mudou?");
  Serial.println("------------+--------+-------");

  const float serie[] = {29.0f, 31.0f, 32.5f, 34.0f, 31.0f, 30.5f, 29.5f};
  const int n = sizeof(serie) / sizeof(serie[0]);
  bool estado = false;
  int mudancas = 0;

  for (int i = 0; i < n; i++) {
    bool novo = proximoEstadoAlerta(serie[i], estado);
    bool mudou = novo != estado;
    if (mudou) mudancas++;
    Serial.print("    ");
    Serial.print(serie[i], 1);
    Serial.print("    | ");
    Serial.print(novo ? "LIGADO  " : "deslig.");
    Serial.print(" | ");
    Serial.println(mudou ? "MUDOU" : "-");
    estado = novo;
  }

  Serial.println();
  Serial.print("mudancas de estado: ");
  Serial.println(mudancas);
  Serial.println("Com um so limite (liga e desliga em 32,0), seriam 4: o painel");
  Serial.println("piscaria em cada leitura perto do limite. Com folga de 2 graus,");
  Serial.println("sao 2: liga uma vez, desliga uma vez.");
}

void setup() {
  Serial.begin(115200);
  Serial.println();
  Serial.println("dia 13 aula 2 — painel com leitura atual e alerta");
  Serial.println("===================================================");

  Serial.print("display: ");
  if (!tela.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("nao achou o display em 0x3C.");
    Serial.println("Se a tela ficar preta: e o endereco (0x3D), nao e o codigo.");
    Serial.println("O monitor serial continua funcionando: e o instrumento de aula.");
  } else {
    Serial.println("SSD1306 128x64 em 0x3C, ok");
  }
  sensor.begin();

  tela.clearDisplay();
  tela.setTextSize(1);
  tela.setCursor(0, 0);
  tela.println("estacao-01");
  tela.println("ligando...");
  tela.display();

  Serial.println();
  Serial.println("--- leitura atual ---");
  float t = sensor.readTemperature();
  float u = sensor.readHumidity();
  int status = 201;   // o POST real e do dia 13 aula 1
  mostrarLeitura(t, u, status);

  if (!isnan(t)) {
    bool novo = proximoEstadoAlerta(t, alertaAtivo);
    if (novo != alertaAtivo) {
      Serial.print("  alerta mudou para ");
      Serial.println(novo ? "LIGADO" : "desligado");
    }
    alertaAtivo = novo;
  }

  mostrarHisterese();

  Serial.println();
  Serial.println("--- a regra do alerta, do lado do Node ---");
  Serial.println("O painel mostra o alerta com a regra do firmware. O banco");
  Serial.println("grava com a regra do service. Se as duas usarem limites");
  Serial.println("diferentes, o aluno ve o alerta na tela e o histoico sem");
  Serial.println("nada. Por isso LIGAR_C e DESLIGAR_C sao os mesmos numeros");
  Serial.println("nos dois lados — e o dia 11 aula 1 que impede que voltem");
  Serial.println("a divergir por causa de um valor hardcoded.");
}

void loop() {
  Serial.println();
  Serial.println("--- nova rodada ---");
  setup();
  delay(10000);
}

Sem saída de compilação gravada. Rode python3 validar.py -t 2 dia13 aula2.