A frustração de tentar fazer a horta 'falar' com a casa. Como 915 MHz, paredes e árvores transformaram um projeto simples num ecossistema distribuído de 3 nós autônomos.
Vitor Neuenschwander
CS Student & Developer
Tudo começou com uma ideia simples: monitorar a umidade do solo da horta e acionar a irrigação automaticamente. Mas entre a horta e a casa havia paredes, árvores e uns bons 50 metros de distância. WiFi? Bluetooth? Nenhum chegava.
Foi aí que descobri o LoRa — Long Range, uma modulação sem fio que promete quilômetros de alcance com consumo mínimo. Parecia perfeito. Não era tão simples.
O projeto que começou como "um sensor e um motor" se transformou no BioChallenge2025-LIPS: um ecossistema distribuído de 3 nós (Jardim, Fogo, Central) com ~1.500 linhas de C++ e um protocolo de comunicação inventado do zero.
O ecossistema BioChallenge tem três dispositivos que conversam entre si via LoRa a 915 MHz:
┌─────────────────────────────────────────────────────────┐
│ ECOSSISTEMA LIPS │
├─────────────────┬──────────────────┬────────────────────┤
│ NÓ JARDIM │ NÓ FOGO │ CENTRAL │
│ 648 linhas │ 465 linhas │ 411 linhas │
│ │ │ │
│ Umidade Solo ×2 │ Gás MQ-2 │ Receptor LoRa │
│ Motor DC ×2 │ Chama KY-026 │ Servidor Web │
│ OLED 128×64 │ OLED 128×64 │ OLED 128×64 │
│ LoRa TX/RX │ LoRa TX/RX │ LoRa RX + API REST │
└─────────────────┴──────────────────┴────────────────────┘
O coração do sistema. Lê a umidade do solo com 2 sensores analógicos (ADC GPIO36 e GPIO37) e, se estiver abaixo do limiar calibrado, aciona 2 motores DC de irrigação (GPIO34, GPIO35, GPIO38, GPIO39).
// Leitura dos sensores de umidade
float umidade1 = analogRead(GPIO36);
float umidade2 = analogRead(GPIO37);
// Calibração: o limiar é definido na inicialização
if (umidade1 < LIMIAR_UMIDO) {
digitalWrite(MOTOR1_A, HIGH); // Liga irrigação
digitalWrite(MOTOR1_B, LOW);
}
Sensor de gás MQ-2 (analógico GPIO36) detecta fumo e gases inflamáveis. O sensor de chama KY-026 (digital GPIO2 com INPUT_PULLUP) funciona com lógica invertida: LOW = chama detectada. Quando algo é detectado, transmite imediatamente via LoRa — aqui não há sleep.
Recebe dados de todos os nós, exibe no OLED em tempo real, e serve uma API REST via HTTP na porta 80 usando ESPAsyncWebServer. IP estático: 192.168.0.65.
O primeiro momento "Aha!" foi perceber que a topologia não podia ser rígida. E se a central caísse? Os nós ficariam cegos, sem interface web, sem acesso remoto.
A solução: negociação dinâmica Mestre/Escravo. Quando um nó liga, ele manda um PING_CENTRAL. Se não recebe um ACK_CENTRAL de volta em 5 segundos, ele mesmo sobe um servidor web e assume o papel de gateway!
// Negociação: quem responde primeiro é a Central
LoRa.beginPacket();
LoRa.print("PING_CENTRAL");
LoRa.endPacket();
unsigned long timeout = millis() + 5000;
while (millis() < timeout) {
int packetSize = LoRa.parsePacket();
if (packetSize > 0) {
String response = LoRa.readString();
if (response == "ACK_CENTRAL") {
isCentral = false; // Já existe central
return;
}
}
}
// Ninguém respondeu — EU sou a central agora
startWebServer();
isCentral = true;
Isso significa que qualquer nó pode virar gateway — Jardim, Fogo, tanto faz. Se a Central "oficial" cair, o sistema se reorganiza sozinho. Resiliência zero-config.
A Central recebe pacotes LoRa de vários nós. Mas como sabe se é umidade do jardim ou um alarme de fogo? Em vez de cabeçalhos complexos ou JSON (impossível em LoRa raw), usei uma técnica em C++ elegantemente brutal: diferenciação por tamanho do pacote em bytes.
Cada nó envia uma struct diferente. Como JardimData tem campos diferentes de GasData, seus tamanhos em bytes são diferentes:
// Structs com tamanhos distintos em memória
struct JardimData {
float umidade[2]; // 8 bytes
};
struct GasData {
float gas_level; // 4 bytes
float flame_status; // 4 bytes (+ padding = tamanho diferente)
};
// Na Central: diferenciação pelo sizeof
int packetSize = LoRa.parsePacket();
if (packetSize == sizeof(JardimData)) {
JardimData dados;
LoRa.readBytes((uint8_t*)&dados, sizeof(dados));
// Atualiza dashboard de irrigação
atualizaOled("Jardim", dados.umidade[0], dados.umidade[1]);
} else if (packetSize == sizeof(GasData)) {
GasData dados;
LoRa.readBytes((uint8_t*)&dados, sizeof(dados));
if (dados.flame_status == 1.0) {
// ALARME! Chama detectada!
triggerAlarm();
}
}
Zero overhead de parsing. Sem strings, sem JSON, sem delimitadores. Apenas bytes contados pelo compilador. Eficiência máxima num microcontrolador com 520 KB de RAM.
A Central (ou qualquer nó que assuma o papel) sobe um servidor HTTP assíncrono na porta 80. Os arquivos HTML são servidos via LittleFS — um filesystem embarcado na flash do ESP32.
// Endpoints da API REST
// GET / → Página HTML (servida do LittleFS)
// GET /umidade → JSON com leituras de umidade
// GET /gasflame → JSON com dados de gás e chama
server.on("/umidade", HTTP_GET, [](AsyncWebServerRequest *request) {
String json = "{";
json += "\"sensor1\": " + String(umidade[0]) + ",";
json += "\"sensor2\": " + String(umidade[1]);
json += "}";
request->send(200, "application/json", json);
});
server.on("/gasflame", HTTP_GET, [](AsyncWebServerRequest *request) {
String json = "{";
json += "\"gas_level\": " + String(gasData.gas_level) + ",";
json += "\"flame_status\": " + String(gasData.flame_status);
json += "}";
request->send(200, "application/json", json);
});
Com isso, qualquer dispositivo na rede WiFi local pode abrir http://192.168.0.65/umidade e ver os dados em tempo real — celular, notebook, outro ESP32.
"Como fazer uma bateria durar meses num ESP32 que consome 240 mA em operação?"
A resposta: Deep Sleep sincronizado com NTP. O sensor acorda, pergunta as horas a um servidor NTP, e toma decisões inteligentes:
// Gestão de energia inteligente
time_t now = time(nullptr);
struct tm *timeinfo = localtime(&now);
int hora = timeinfo->tm_hour;
if (hora >= 18 || hora < 8) {
// Noite: dormir até as 08h do dia seguinte
int horasAteManha = (hora >= 18) ? (24 - hora + 8) : (8 - hora);
esp_deep_sleep(horasAteManha * 3600ULL * 1000000ULL);
} else {
// Dia: dormir 2 horas
esp_deep_sleep(2 * 3600ULL * 1000000ULL);
}
No Deep Sleep, o ESP32 consome apenas ~10 µA. A diferença entre 1 dia de bateria e 6 meses.
Cada nó carrega uma combinação destas bibliotecas:
Resultados com antena padrão (sem amplificador externo), Spreading Factor 12:
LoRa não é WiFi — é lento (~300 bps), limitado, e não serve para streaming. Mas para IoT é imbatível. Este projeto me ensinou três coisas:
sizeof() como diferenciador de pacotes é genial na sua brutalidade