::: Projeto Integrador 3 — IFSC Sao Jose

Estacao
Meteorologica IoT

Documentacao tecnica do sistema de monitoramento meteorologico baseado em ESP32, com tres opcoes de arquitetura de comunicacao, backend Python, TimescaleDB e dashboards Grafana.

>_
3 Arquiteturas
MQTT / HTTP / LoRaWAN
uC
ESP32 DevKit
WiFi + BLE + LoRa
|-
Stack
FastAPI + TimescaleDB + Grafana
[ ]
Docker Compose
Deploy em 1 comando

Arquiteturas de comunicacao

Tres opcoes de protocolo para o enlace ESP32 -> Backend. Os alunos implementam pelo menos duas e comparam latencia, overhead e confiabilidade.

</>
Recomendada

MQTT — Pub/Sub

Protocolo publish/subscribe padrão IoT com broker Mosquitto. Suporte a QoS 0/1/2, retained messages, wildcards e last will testament.

ESP32+Sensores → Mosquitto Broker → Backend Python → TimescaleDB → Grafana
Protocolo MQTT v3.1.1 / v5
Porta 1883 / 8883 (TLS)
QoS 0, 1, 2
Latência ~50-200 ms
{..}
Mais Simples

HTTP/REST — Request/Response

API RESTful com FastAPI. Endpoints claros, documentação automática Swagger, fácil debug com curl/Postman, autenticação via API key.

ESP32+Sensores → FastAPI REST → TimescaleDB → Grafana
Protocolo HTTP/1.1 + JSON
Porta 8000 (API)
Método POST /api/data
Latência ~100-500 ms
~RF
Avançada

LoRaWAN — Longo Alcance

Comunicação RF de longo alcance com modulação CSS. Integração via The Things Network (TTN), conceitos de link budget e sensibilidade.

ESP32+LoRa → Gateway LoRa → TTN → Webhook → Backend → DB
Frequência 915 MHz (AU915)
Alcance 2-15 km
SF (Spread) 7-12
Payload até 242 bytes

Bill of Materials

Componentes, interfaces de comunicacao e especificacoes tecnicas.

Componente Modelo Interface Especificações
Microcontrolador ESP32 DevKit V1 WiFi BLE GPIO 240MHz dual-core, 520KB SRAM, 4MB Flash, 34 GPIO
Sensor Temp/Umid/Press BME280 I2C SPI Temp: -40~85°C (±1°C), Umid: 0~100% (±3%), Press: 300~1100 hPa (±1 hPa)
Sensor Temp/Umid (Alt.) DHT22 / AM2302 1-Wire Temp: -40~80°C (±0.5°C), Umid: 0~100% (±2-5%), leitura a cada 2s
Anemômetro Pulso (reed switch) INT GPIO Saída em pulsos, calibrar: 1 pulso/s ≈ 2.4 km/h (típico)
Pluviômetro Tipping bucket INT GPIO Cada tip ≈ 0.2794 mm de chuva, contagem por interrupção
Módulo LoRa (Opc. 3) SX1276 / RFM95W SPI 915 MHz, +20 dBm TX, -148 dBm sensibilidade, SF7-SF12
Display (Opcional) OLED SSD1306 0.96" I2C 128×64 pixels, endereço 0x3C, exibe status e leituras locais

Mapa de Pinagem — ESP32

BME280 (I2C)
VCC3.3V
GNDGND
SDAGPIO 21
SCLGPIO 22
DHT22 (1-Wire)
VCC3.3V
GNDGND
DATAGPIO 4
NotaPull-up 10kΩ
Anemômetro (INT)
SinalGPIO 34
GNDGND
NotaINPUT_ONLY, pull-up ext.
Pluviômetro (INT)
SinalGPIO 35
GNDGND
NotaINPUT_ONLY, debounce SW
LoRa SX1276 (SPI)
SCKGPIO 5
MISOGPIO 19
MOSIGPIO 27
NSSGPIO 18
RSTGPIO 14
DIO0GPIO 26
OLED SSD1306 (I2C)
VCC3.3V
GNDGND
SDAGPIO 21
SCLGPIO 22

Firmware ESP32

C++ (Arduino Framework / PlatformIO). Leitura de BME280 via I2C, anemometro e pluviometro por interrupcao de hardware.

src/main_mqtt.cpp
C++ / Arduino
// ═══════════════════════════════════════════════════════════
//  Estação Meteorológica IoT — Firmware MQTT
//  PI3 — Eng. Telecomunicações — IFSC São José
// ═══════════════════════════════════════════════════════════

#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <ArduinoJson.h>

// ── Configuração WiFi ──
const char* WIFI_SSID     = "SUA_REDE_WIFI";
const char* WIFI_PASSWORD = "SUA_SENHA_WIFI";

// ── Configuração MQTT ──
const char* MQTT_BROKER   = "192.168.1.100";  // IP do servidor
const int   MQTT_PORT     = 1883;
const char* MQTT_TOPIC    = "estacao/sensores";
const char* MQTT_CLIENT   = "ESP32_Estacao_01";
const char* MQTT_USER     = "estacao";
const char* MQTT_PASS     = "senha_mqtt";

// ── Pinos ──
#define PIN_ANEMOMETRO  34
#define PIN_PLUVIOMETRO 35
#define INTERVALO_LEITURA 30000  // 30 segundos

// ── Objetos ──
WiFiClient espClient;
PubSubClient mqtt(espClient);
Adafruit_BME280 bme;

// ── Variáveis voláteis (interrupções) ──
volatile unsigned long pulsos_vento = 0;
volatile unsigned long pulsos_chuva = 0;
unsigned long ultimo_envio = 0;
unsigned long ultimo_debounce_chuva = 0;

// ── ISRs (Interrupt Service Routines) ──
void IRAM_ATTR isr_anemometro() {
  pulsos_vento++;
}

void IRAM_ATTR isr_pluviometro() {
  unsigned long agora = millis();
  if (agora - ultimo_debounce_chuva > 300) { // debounce 300ms
    pulsos_chuva++;
    ultimo_debounce_chuva = agora;
  }
}

// ── Conexão WiFi ──
void conectar_wifi() {
  Serial.printf("Conectando WiFi: %s", WIFI_SSID);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\nWiFi OK! IP: %s\n", WiFi.localIP().toString().c_str());
}

// ── Conexão MQTT ──
void conectar_mqtt() {
  while (!mqtt.connected()) {
    Serial.print("Conectando MQTT...");
    if (mqtt.connect(MQTT_CLIENT, MQTT_USER, MQTT_PASS)) {
      Serial.println("OK!");
      // Publica status online (retained)
      mqtt.publish("estacao/status", "online", true);
    } else {
      Serial.printf("Falha rc=%d. Retry em 5s...\n", mqtt.state());
      delay(5000);
    }
  }
}

// ── Calcula velocidade do vento ──
float calcular_vento_kmh(unsigned long pulsos, unsigned long dt_ms) {
  if (dt_ms == 0) return 0.0;
  float freq = (float)pulsos / ((float)dt_ms / 1000.0);
  return freq * 2.4;  // Fator de calibração típico
}

// ── Calcula precipitação ──
float calcular_chuva_mm(unsigned long pulsos) {
  return pulsos * 0.2794;  // mm por tip
}

void setup() {
  Serial.begin(115200);
  Serial.println("\n═══ Estação Meteorológica IoT ═══");

  // Inicializa BME280
  Wire.begin(21, 22);
  if (!bme.begin(0x76)) {
    Serial.println("[ERRO] BME280 não encontrado!");
    while(1) delay(1000);
  }
  Serial.println("[OK] BME280 inicializado");

  // Configura interrupções
  pinMode(PIN_ANEMOMETRO, INPUT);
  pinMode(PIN_PLUVIOMETRO, INPUT);
  attachInterrupt(digitalPinToInterrupt(PIN_ANEMOMETRO), isr_anemometro, FALLING);
  attachInterrupt(digitalPinToInterrupt(PIN_PLUVIOMETRO), isr_pluviometro, FALLING);

  // Conecta WiFi e MQTT
  conectar_wifi();
  mqtt.setServer(MQTT_BROKER, MQTT_PORT);
  conectar_mqtt();
}

void loop() {
  if (!mqtt.connected()) conectar_mqtt();
  mqtt.loop();

  unsigned long agora = millis();
  if (agora - ultimo_envio >= INTERVALO_LEITURA) {
    unsigned long dt = agora - ultimo_envio;

    // Lê sensores
    float temperatura = bme.readTemperature();
    float umidade     = bme.readHumidity();
    float pressao     = bme.readPressure() / 100.0;

    // Calcula vento e chuva
    noInterrupts();
    unsigned long p_vento = pulsos_vento;
    unsigned long p_chuva = pulsos_chuva;
    pulsos_vento = 0;
    interrupts();

    float vento_kmh = calcular_vento_kmh(p_vento, dt);
    float chuva_mm  = calcular_chuva_mm(p_chuva);

    // Monta JSON
    JsonDocument doc;
    doc["station_id"]   = MQTT_CLIENT;
    doc["temperatura"]  = round(temperatura * 100) / 100.0;
    doc["umidade"]      = round(umidade * 100) / 100.0;
    doc["pressao"]      = round(pressao * 100) / 100.0;
    doc["vento_kmh"]    = round(vento_kmh * 100) / 100.0;
    doc["chuva_mm"]     = chuva_mm;

    char payload[256];
    serializeJson(doc, payload);

    // Publica com QoS 1 (at least once)
    if (mqtt.publish(MQTT_TOPIC, payload)) {
      Serial.printf("[MQTT] Publicado: %s\n", payload);
    }

    ultimo_envio = agora;
  }
}
src/main_http.cpp
C++ / Arduino
// ═══════════════════════════════════════════════════════════
//  Estação Meteorológica IoT — Firmware HTTP/REST
//  PI3 — Eng. Telecomunicações — IFSC São José
// ═══════════════════════════════════════════════════════════

#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <ArduinoJson.h>

// ── Configuração ──
const char* WIFI_SSID     = "SUA_REDE_WIFI";
const char* WIFI_PASSWORD = "SUA_SENHA_WIFI";
const char* API_URL       = "http://192.168.1.100:8000/api/v1/weather";
const char* API_KEY       = "chave-secreta-estacao-01";
const char* STATION_ID    = "ESP32_Estacao_01";

#define PIN_ANEMOMETRO  34
#define PIN_PLUVIOMETRO 35
#define INTERVALO_LEITURA 30000

Adafruit_BME280 bme;
volatile unsigned long pulsos_vento = 0;
volatile unsigned long pulsos_chuva = 0;
unsigned long ultimo_envio = 0;
unsigned long ultimo_debounce_chuva = 0;

void IRAM_ATTR isr_anemometro()  { pulsos_vento++; }
void IRAM_ATTR isr_pluviometro() {
  unsigned long agora = millis();
  if (agora - ultimo_debounce_chuva > 300) {
    pulsos_chuva++;
    ultimo_debounce_chuva = agora;
  }
}

void conectar_wifi() {
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.printf("WiFi OK! IP: %s\n", WiFi.localIP().toString().c_str());
}

// ── Envia dados via HTTP POST ──
void enviar_http(float temp, float umid, float press, float vento, float chuva) {
  HTTPClient http;
  http.begin(API_URL);
  http.addHeader("Content-Type", "application/json");
  http.addHeader("X-API-Key", API_KEY);

  JsonDocument doc;
  doc["station_id"]  = STATION_ID;
  doc["temperatura"] = temp;
  doc["umidade"]     = umid;
  doc["pressao"]     = press;
  doc["vento_kmh"]   = vento;
  doc["chuva_mm"]    = chuva;

  char payload[256];
  serializeJson(doc, payload);

  int httpCode = http.POST(payload);
  if (httpCode == 201) {
    Serial.printf("[HTTP] POST OK (201): %s\n", payload);
  } else {
    Serial.printf("[HTTP] Erro: %d — %s\n", httpCode, http.errorToString(httpCode).c_str());
  }
  http.end();
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  bme.begin(0x76);
  pinMode(PIN_ANEMOMETRO, INPUT);
  pinMode(PIN_PLUVIOMETRO, INPUT);
  attachInterrupt(digitalPinToInterrupt(PIN_ANEMOMETRO), isr_anemometro, FALLING);
  attachInterrupt(digitalPinToInterrupt(PIN_PLUVIOMETRO), isr_pluviometro, FALLING);
  conectar_wifi();
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) conectar_wifi();

  unsigned long agora = millis();
  if (agora - ultimo_envio >= INTERVALO_LEITURA) {
    unsigned long dt = agora - ultimo_envio;

    noInterrupts();
    unsigned long pv = pulsos_vento; pulsos_vento = 0;
    unsigned long pc = pulsos_chuva;
    interrupts();

    enviar_http(
      bme.readTemperature(),
      bme.readHumidity(),
      bme.readPressure() / 100.0,
      (dt > 0) ? (float)pv / ((float)dt/1000.0) * 2.4 : 0,
      pc * 0.2794
    );
    ultimo_envio = agora;
  }
}
src/main_lorawan.cpp
C++ / Arduino + LMIC
// ═══════════════════════════════════════════════════════════
//  Estação Meteorológica IoT — Firmware LoRaWAN (TTN)
//  PI3 — Eng. Telecomunicações — IFSC São José
//  Usa biblioteca LMIC (MCCI LoRaWAN LMIC Library)
// ═══════════════════════════════════════════════════════════

#include <lmic.h>
#include <hal/hal.h>
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_BME280.h>

// ── TTN OTAA Keys (substitua pelas suas!) ──
static const u1_t PROGMEM APPEUI[8]  = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const u1_t PROGMEM DEVEUI[8]  = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const u1_t PROGMEM APPKEY[16] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
                                          0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };

void os_getArtEui(u1_t* buf) { memcpy_P(buf, APPEUI, 8); }
void os_getDevEui(u1_t* buf) { memcpy_P(buf, DEVEUI, 8); }
void os_getDevKey(u1_t* buf) { memcpy_P(buf, APPKEY, 16); }

// ── Pin mapping LoRa SX1276 ──
const lmic_pinmap lmic_pins = {
  .nss  = 18,
  .rxtx = LMIC_UNUSED_PIN,
  .rst  = 14,
  .dio  = {26, 33, 32},
};

Adafruit_BME280 bme;
static osjob_t sendjob;
const unsigned TX_INTERVAL = 300; // 5 minutos (LoRa = baixa frequência)

// ── Empacota payload (binário, eficiente) ──
void empacotar_payload(uint8_t* buf) {
  int16_t temp  = (int16_t)(bme.readTemperature() * 100);
  uint16_t umid = (uint16_t)(bme.readHumidity() * 100);
  uint16_t pres = (uint16_t)(bme.readPressure() / 10); // em 0.1 hPa

  buf[0] = temp >> 8;    buf[1] = temp & 0xFF;
  buf[2] = umid >> 8;    buf[3] = umid & 0xFF;
  buf[4] = pres >> 8;    buf[5] = pres & 0xFF;
  // Bytes 6-9: vento e chuva (implementar similar)
}

// ── Envia payload LoRaWAN ──
void do_send(osjob_t* j) {
  if (LMIC.opmode & OP_TXRXPEND) {
    Serial.println("[LoRa] TX pendente, ignorando");
  } else {
    uint8_t payload[10];
    empacotar_payload(payload);
    LMIC_setTxData2(1, payload, sizeof(payload), 0);
    Serial.println("[LoRa] Pacote enfileirado");
  }
}

// ── Callback de eventos LMIC ──
void onEvent(ev_t ev) {
  switch(ev) {
    case EV_JOINED:
      Serial.println("[LoRa] Join aceito!");
      LMIC_setLinkCheckMode(0);
      break;
    case EV_TXCOMPLETE:
      Serial.println("[LoRa] TX completo");
      // Agenda próximo envio
      os_setTimedCallback(&sendjob,
        os_getTime() + sec2osticks(TX_INTERVAL), do_send);
      break;
    case EV_JOIN_FAILED:
      Serial.println("[LoRa] Join falhou!");
      break;
    default:
      break;
  }
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  bme.begin(0x76);

  // Inicializa LMIC
  os_init();
  LMIC_reset();
  // Configura para AU915 (Brasil)
  LMIC_selectSubBand(1); // Sub-band 2 (canais 8-15 + 65)

  // Inicia join OTAA + primeiro envio
  do_send(&sendjob);
}

void loop() {
  os_runloop_once();
}

Backend Python

FastAPI para rota HTTP, subscriber Paho-MQTT para rota pub/sub. Persistencia em TimescaleDB, validacao com Pydantic.

app/main.py
Python / FastAPI
# ═══════════════════════════════════════════════════════════
#  Backend FastAPI — Estação Meteorológica IoT
#  PI3 — Eng. Telecomunicações — IFSC São José
# ═══════════════════════════════════════════════════════════

from fastapi import FastAPI, HTTPException, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
from datetime import datetime, timedelta
from typing import Optional
import asyncpg
import os

from app.models import WeatherData, WeatherResponse

app = FastAPI(
    title="Estacao Meteorologica IoT API",
    version="1.0.0",
    description="API REST para receber e consultar dados meteorológicos"
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Database pool ──
DATABASE_URL = os.getenv("DATABASE_URL",
    "postgresql://postgres:postgres@timescaledb:5432/weather")
API_KEY = os.getenv("API_KEY", "chave-secreta-estacao-01")

pool: asyncpg.Pool = None

@app.on_event("startup")
async def startup():
    global pool
    pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)

@app.on_event("shutdown")
async def shutdown():
    await pool.close()

# ── Autenticação simples ──
async def verify_api_key(x_api_key: str = Header(...)):
    if x_api_key != API_KEY:
        raise HTTPException(status_code=401, detail="API Key inválida")

# ── POST: Recebe dados do ESP32 ──
@app.post("/api/v1/weather", status_code=201)
async def create_weather_data(
    data: WeatherData,
    _: None = Depends(verify_api_key)
):
    query = """
        INSERT INTO weather_data
            (time, station_id, temperatura, umidade, pressao, vento_kmh, chuva_mm)
        VALUES (NOW(), $1, $2, $3, $4, $5, $6)
        RETURNING time
    """
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            query, data.station_id,
            data.temperatura, data.umidade, data.pressao,
            data.vento_kmh, data.chuva_mm
        )
    return {"status": "ok", "timestamp": row["time"].isoformat()}

# ── GET: Consulta últimas leituras ──
@app.get("/api/v1/weather/latest")
async def get_latest(station_id: Optional[str] = None, limit: int = 100):
    query = """
        SELECT * FROM weather_data
        WHERE ($1::text IS NULL OR station_id = $1)
        ORDER BY time DESC
        LIMIT $2
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(query, station_id, limit)
    return [dict(r) for r in rows]

# ── GET: Agregações temporais ──
@app.get("/api/v1/weather/aggregate")
async def get_aggregate(
    interval: str = "1 hour",
    hours_back: int = 24
):
    query = """
        SELECT
            time_bucket($1::interval, time) AS bucket,
            AVG(temperatura) AS avg_temp,
            AVG(umidade) AS avg_umid,
            AVG(pressao) AS avg_press,
            MAX(vento_kmh) AS max_vento,
            SUM(chuva_mm) AS total_chuva,
            COUNT(*) AS amostras
        FROM weather_data
        WHERE time > NOW() - ($2 || ' hours')::interval
        GROUP BY bucket
        ORDER BY bucket DESC
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(query, interval, str(hours_back))
    return [dict(r) for r in rows]

# ── Health check ──
@app.get("/health")
async def health():
    return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
app/mqtt_subscriber.py
Python / Paho-MQTT
# ═══════════════════════════════════════════════════════════
#  MQTT Subscriber — Recebe dados do broker e grava no DB
# ═══════════════════════════════════════════════════════════

import json
import os
import psycopg2
from psycopg2.extras import RealDictCursor
import paho.mqtt.client as mqtt

# ── Configuração ──
MQTT_BROKER = os.getenv("MQTT_BROKER", "mosquitto")
MQTT_PORT   = int(os.getenv("MQTT_PORT", "1883"))
MQTT_TOPIC  = os.getenv("MQTT_TOPIC", "estacao/#")
DB_URL      = os.getenv("DATABASE_URL",
    "postgresql://postgres:postgres@timescaledb:5432/weather")

# ── Conexão DB ──
def get_db_conn():
    return psycopg2.connect(DB_URL)

# ── Callbacks MQTT ──
def on_connect(client, userdata, flags, rc):
    if rc == 0:
        print(f"[MQTT] Conectado ao broker {MQTT_BROKER}:{MQTT_PORT}")
        client.subscribe(MQTT_TOPIC, qos=1)
        print(f"[MQTT] Inscrito em: {MQTT_TOPIC}")
    else:
        print(f"[MQTT] Falha na conexão, rc={rc}")

def on_message(client, userdata, msg):
    try:
        payload = json.loads(msg.payload.decode())
        print(f"[MQTT] {msg.topic}: {payload}")

        # Filtra só mensagens de sensores
        if "sensores" not in msg.topic:
            return

        conn = get_db_conn()
        with conn.cursor() as cur:
            cur.execute("""
                INSERT INTO weather_data
                    (time, station_id, temperatura, umidade,
                     pressao, vento_kmh, chuva_mm)
                VALUES (NOW(), %s, %s, %s, %s, %s, %s)
            """, (
                payload.get("station_id", "unknown"),
                payload.get("temperatura"),
                payload.get("umidade"),
                payload.get("pressao"),
                payload.get("vento_kmh", 0),
                payload.get("chuva_mm", 0),
            ))
            conn.commit()
        conn.close()
        print("[DB] Dados inseridos com sucesso")

    except Exception as e:
        print(f"[ERRO] {e}")

# ── Main ──
if __name__ == "__main__":
    client = mqtt.Client(client_id="weather-subscriber")
    client.username_pw_set("estacao", "senha_mqtt")
    client.on_connect = on_connect
    client.on_message = on_message

    print("[MQTT] Iniciando subscriber...")
    client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
    client.loop_forever()
db/init.sql
SQL / TimescaleDB
-- ═══════════════════════════════════════════════════════════
--  Inicialização TimescaleDB — Estação Meteorológica IoT
-- ═══════════════════════════════════════════════════════════

-- Habilita extensão TimescaleDB
CREATE EXTENSION IF NOT EXISTS timescaledb;

-- Tabela principal de dados meteorológicos
CREATE TABLE IF NOT EXISTS weather_data (
    time          TIMESTAMPTZ    NOT NULL,
    station_id    TEXT           NOT NULL,
    temperatura   DOUBLE PRECISION,
    umidade       DOUBLE PRECISION,
    pressao       DOUBLE PRECISION,
    vento_kmh     DOUBLE PRECISION DEFAULT 0,
    chuva_mm      DOUBLE PRECISION DEFAULT 0
);

-- Converte para hypertable (particionamento temporal automático)
SELECT create_hypertable(
    'weather_data',
    'time',
    if_not_exists => TRUE
);

-- Índice para consultas por estação
CREATE INDEX IF NOT EXISTS idx_station_time
    ON weather_data (station_id, time DESC);

-- ── Continuous Aggregate: média horária ──
CREATE MATERIALIZED VIEW IF NOT EXISTS weather_hourly
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', time) AS bucket,
    station_id,
    AVG(temperatura)  AS avg_temp,
    MIN(temperatura)  AS min_temp,
    MAX(temperatura)  AS max_temp,
    AVG(umidade)      AS avg_umid,
    AVG(pressao)      AS avg_press,
    MAX(vento_kmh)    AS max_vento,
    SUM(chuva_mm)     AS total_chuva,
    COUNT(*)          AS amostras
FROM weather_data
GROUP BY bucket, station_id
WITH NO DATA;

-- Política de refresh automático
SELECT add_continuous_aggregate_policy('weather_hourly',
    start_offset  => INTERVAL '3 hours',
    end_offset    => INTERVAL '1 hour',
    schedule_interval => INTERVAL '1 hour',
    if_not_exists => TRUE
);

-- Política de retenção: remove dados brutos após 90 dias
SELECT add_retention_policy('weather_data',
    INTERVAL '90 days',
    if_not_exists => TRUE
);

-- Insere dados de exemplo para teste
INSERT INTO weather_data (time, station_id, temperatura, umidade, pressao, vento_kmh, chuva_mm)
VALUES
    (NOW() - INTERVAL '2 hours', 'ESP32_Estacao_01', 24.5, 68.2, 1013.25, 12.3, 0.0),
    (NOW() - INTERVAL '1 hour',  'ESP32_Estacao_01', 23.8, 71.5, 1012.80, 15.7, 0.28),
    (NOW(),                       'ESP32_Estacao_01', 22.1, 78.3, 1011.50, 8.9,  1.12);
app/models.py
Python / Pydantic
# ═══════════════════════════════════════════════════════════
#  Modelos Pydantic — Validação de dados
# ═══════════════════════════════════════════════════════════

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

class WeatherData(BaseModel):
    """Payload recebido do ESP32"""
    station_id:  str   = Field(..., example="ESP32_Estacao_01")
    temperatura: float = Field(..., ge=-50, le=60,  description="°C")
    umidade:     float = Field(..., ge=0,   le=100, description="%RH")
    pressao:     float = Field(..., ge=300, le=1100, description="hPa")
    vento_kmh:   float = Field(0,   ge=0,   le=300, description="km/h")
    chuva_mm:    float = Field(0,   ge=0,              description="mm")

class WeatherResponse(BaseModel):
    """Resposta da API"""
    time:        datetime
    station_id:  str
    temperatura: float
    umidade:     float
    pressao:     float
    vento_kmh:   float
    chuva_mm:    float

Docker Compose

Cinco servicos: TimescaleDB, Mosquitto, FastAPI, MQTT Subscriber e Grafana. Rede interna isolada, volumes persistentes.

[#]

Docker Compose

docker compose up -d

x5

5 Servicos

DB, Broker, API, Subscriber, Grafana

IO

Volumes Persistentes

Dados sobrevivem a restart

--

Rede Isolada

Bridge network entre containers

docker-compose.yml
YAML
# ═══════════════════════════════════════════════════════════
#  Docker Compose — Estação Meteorológica IoT
#  PI3 — Eng. Telecomunicações — IFSC São José
#
#  Uso: docker compose up -d
# ═══════════════════════════════════════════════════════════

version: "3.8"

services:
  # ── TimescaleDB (PostgreSQL otimizado para time-series) ──
  timescaledb:
    image: timescale/timescaledb:latest-pg16
    container_name: weather-db
    environment:
      POSTGRES_DB: weather
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 5

  # ── Mosquitto MQTT Broker ──
  mosquitto:
    image: eclipse-mosquitto:2
    container_name: weather-mqtt
    ports:
      - "1883:1883"
      - "9001:9001"   # WebSocket
    volumes:
      - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf
      - ./mosquitto/password.txt:/mosquitto/config/password.txt
      - mqttdata:/mosquitto/data

  # ── Backend FastAPI ──
  api:
    build: .
    container_name: weather-api
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
    environment:
      DATABASE_URL: postgresql://postgres:postgres@timescaledb:5432/weather
      API_KEY: chave-secreta-estacao-01
    ports:
      - "8000:8000"
    depends_on:
      timescaledb:
        condition: service_healthy

  # ── MQTT Subscriber (persiste dados MQTT no DB) ──
  mqtt-subscriber:
    build: .
    container_name: weather-mqtt-sub
    command: python -m app.mqtt_subscriber
    environment:
      MQTT_BROKER: mosquitto
      MQTT_PORT: "1883"
      DATABASE_URL: postgresql://postgres:postgres@timescaledb:5432/weather
    depends_on:
      - mosquitto
      - timescaledb

  # ── Grafana (Dashboards) ──
  grafana:
    image: grafana/grafana:latest
    container_name: weather-grafana
    environment:
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: admin
      GF_INSTALL_PLUGINS: grafana-clock-panel
    ports:
      - "3000:3000"
    volumes:
      - grafanadata:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning
    depends_on:
      - timescaledb

volumes:
  pgdata:
  mqttdata:
  grafanadata:

networks:
  default:
    name: weather-network
mosquitto/mosquitto.conf
Config
# Configuração Mosquitto MQTT Broker
listener 1883
protocol mqtt

listener 9001
protocol websockets

# Autenticação
allow_anonymous false
password_file /mosquitto/config/password.txt

# Persistência
persistence true
persistence_location /mosquitto/data/

# Logging
log_type all
log_dest stdout
Dockerfile
Docker
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
requirements.txt
PIP
fastapi==0.115.*
uvicorn[standard]==0.34.*
asyncpg==0.30.*
psycopg2-binary==2.9.*
paho-mqtt==2.1.*
pydantic==2.10.*

Dashboards Grafana

Datasource PostgreSQL/TimescaleDB. Auto-refresh 10s. Provisioning automatico via YAML.

[T] Temperatura

Série temporal com min/avg/max, gradiente vermelho-azul, threshold de alerta acima de 35°C

Time Series + Stat Panel

[H] Umidade Relativa

Gauge circular 0-100%, cores adaptativas. Tabela com histórico e sparkline

Gauge + Table

[P] Pressao Atmosferica

Gráfico com eixo Y ajustado (990-1040 hPa), tendência para previsão de tempo

Time Series + Trend

[W] Velocidade do Vento

Bar gauge horizontal com escala Beaufort, rajada máxima em stat panel

Bar Gauge + Stat

[R] Precipitacao

Gráfico de barras (mm/hora), acumulado diário em stat panel, cores de intensidade

Bar Chart + Stat

[!] Alertas e Status

Regras de alerta: temp > 35°C, vento > 60 km/h, sem dados > 5 min. Status da estação

Alert List + State Timeline
grafana/provisioning/datasources/timescaledb.yml
YAML
apiVersion: 1

datasources:
  - name: TimescaleDB
    type: postgres
    access: proxy
    url: timescaledb:5432
    database: weather
    user: postgres
    secureJsonData:
      password: postgres
    jsonData:
      sslmode: disable
      maxOpenConns: 10
      postgresVersion: 1600
      timescaledb: true
    isDefault: true
    editable: true
Queries Grafana — Exemplos
SQL
-- ═══ Painel: Temperatura em tempo real ═══
SELECT
  time AS "time",
  temperatura AS "Temperatura (°C)"
FROM weather_data
WHERE
  $__timeFilter(time)
  AND station_id = 'ESP32_Estacao_01'
ORDER BY time;

-- ═══ Painel: Média horária (usa Continuous Aggregate) ═══
SELECT
  bucket AS "time",
  avg_temp  AS "Média",
  min_temp  AS "Mínima",
  max_temp  AS "Máxima"
FROM weather_hourly
WHERE
  $__timeFilter(bucket)
  AND station_id = 'ESP32_Estacao_01'
ORDER BY bucket;

-- ═══ Painel: Precipitação acumulada por hora ═══
SELECT
  time_bucket('1 hour', time) AS "time",
  SUM(chuva_mm) AS "Chuva (mm)"
FROM weather_data
WHERE $__timeFilter(time)
GROUP BY 1
ORDER BY 1;

-- ═══ Painel: Stat — Último valor de cada grandeza ═══
SELECT
  temperatura AS "Temp (°C)",
  umidade     AS "Umid (%)",
  pressao     AS "Press (hPa)",
  vento_kmh   AS "Vento (km/h)"
FROM weather_data
ORDER BY time DESC
LIMIT 1;

Cronograma — 14 semanas

Distribuicao sugerida para um semestre letivo.

Semanas 1-2

Planejamento e Setup

Definição de escopo, compra de componentes, instalação do Docker e PlatformIO, configuração do ambiente de desenvolvimento.

Semanas 3-4

Hardware e Sensores

Montagem do circuito na protoboard, testes individuais de cada sensor (BME280, anemômetro, pluviômetro), calibração.

Semanas 5-6

Arquitetura HTTP/REST

Implementação do firmware HTTP no ESP32, backend FastAPI, banco TimescaleDB. Validação ponta a ponta.

Semanas 7-8

Arquitetura MQTT

Configuração do Mosquitto, implementação do firmware MQTT, subscriber Python. Testes de QoS e retained messages.

Semanas 9-10

Dashboards Grafana

Paineis, queries otimizadas, continuous aggregates, regras de alerta. Dashboard funcional.

Semanas 11-12

LoRaWAN (Opcional) + Testes

Implementação LoRa com TTN (se disponível), testes de campo, comparação de performance entre arquiteturas.

Semanas 13-14

Documentação e Apresentação

Relatório técnico final, análise comparativa dos protocolos, vídeo demonstrativo, preparação da apresentação.

Competencias tecnicas

>_

Protocolos de Comunicação

MQTT (pub/sub, QoS, topics), HTTP/REST (verbos, status codes, JSON), LoRaWAN (modulação CSS, SF, link budget)

uC

IoT e Sistemas Embarcados

ESP32 (GPIO, I2C, SPI, interrupções), sensores analógicos e digitais, gerenciamento de energia, OTA updates

/.

Backend e APIs RESTful

FastAPI (async, Pydantic, OpenAPI), autenticação (API keys), pool de conexões, middleware CORS

DB

Bancos de Dados Time-Series

TimescaleDB (hypertables, continuous aggregates, retention policies), SQL avançado, otimização de queries

|-

Visualização de Dados

Grafana (datasources, panels, alertas, provisioning), design de dashboards, métricas e KPIs

[#]

DevOps e Containerização

Docker (Dockerfile, compose), redes entre containers, volumes persistentes, health checks, deploy automatizado

Arvore de diretorios

Árvore de diretórios
Tree
estacao-meteorologica-iot/
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── README.md
│
├── app/                        # Backend Python
│   ├── __init__.py
│   ├── main.py                 # FastAPI (rota HTTP)
│   ├── models.py               # Pydantic schemas
│   └── mqtt_subscriber.py      # Subscriber MQTT → DB
│
├── db/                         # Database
│   └── init.sql                # Schema + hypertable + seeds
│
├── firmware/                   # Código ESP32 (PlatformIO)
│   ├── platformio.ini
│   └── src/
│       ├── main_mqtt.cpp       # Firmware opção 1: MQTT
│       ├── main_http.cpp       # Firmware opção 2: HTTP
│       └── main_lorawan.cpp    # Firmware opção 3: LoRaWAN
│
├── mosquitto/                  # Config do broker MQTT
│   ├── mosquitto.conf
│   └── password.txt
│
├── grafana/                    # Dashboards e provisioning
│   └── provisioning/
│       ├── datasources/
│       │   └── timescaledb.yml
│       └── dashboards/
│           └── weather.json
│
└── docs/                       # Documentação
    ├── arquitetura.md
    ├── guia-montagem.md
    └── analise-protocolos.md