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.
Arquiteturas de comunicacao
Tres opcoes de protocolo para o enlace ESP32 -> Backend. Os alunos implementam pelo menos duas e comparam latencia, overhead e confiabilidade.
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.
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.
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.
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
Firmware ESP32
C++ (Arduino Framework / PlatformIO). Leitura de BME280 via I2C, anemometro e pluviometro por interrupcao de hardware.
// ═══════════════════════════════════════════════════════════ // 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; } }
// ═══════════════════════════════════════════════════════════ // 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; } }
// ═══════════════════════════════════════════════════════════ // 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.
# ═══════════════════════════════════════════════════════════ # 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()}
# ═══════════════════════════════════════════════════════════ # 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()
-- ═══════════════════════════════════════════════════════════ -- 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);
# ═══════════════════════════════════════════════════════════ # 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
5 Servicos
DB, Broker, API, Subscriber, Grafana
Volumes Persistentes
Dados sobrevivem a restart
Rede Isolada
Bridge network entre containers
# ═══════════════════════════════════════════════════════════ # 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
# 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
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"]
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
[H] Umidade Relativa
Gauge circular 0-100%, cores adaptativas. Tabela com histórico e sparkline
[P] Pressao Atmosferica
Gráfico com eixo Y ajustado (990-1040 hPa), tendência para previsão de tempo
[W] Velocidade do Vento
Bar gauge horizontal com escala Beaufort, rajada máxima em stat panel
[R] Precipitacao
Gráfico de barras (mm/hora), acumulado diário em stat panel, cores de intensidade
[!] Alertas e Status
Regras de alerta: temp > 35°C, vento > 60 km/h, sem dados > 5 min. Status da estação
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
-- ═══ 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.
Planejamento e Setup
Definição de escopo, compra de componentes, instalação do Docker e PlatformIO, configuração do ambiente de desenvolvimento.
Hardware e Sensores
Montagem do circuito na protoboard, testes individuais de cada sensor (BME280, anemômetro, pluviômetro), calibração.
Arquitetura HTTP/REST
Implementação do firmware HTTP no ESP32, backend FastAPI, banco TimescaleDB. Validação ponta a ponta.
Arquitetura MQTT
Configuração do Mosquitto, implementação do firmware MQTT, subscriber Python. Testes de QoS e retained messages.
Dashboards Grafana
Paineis, queries otimizadas, continuous aggregates, regras de alerta. Dashboard funcional.
LoRaWAN (Opcional) + Testes
Implementação LoRa com TTN (se disponível), testes de campo, comparação de performance entre arquiteturas.
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)
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
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
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