
BermejaSat #12: Sistema Completo de Telemetría: Emisor CanSat (ESP-NOW), Receptor y Estación Terrena en Python
Introducción
En el desarrollo de proyectos aeroespaciales, la flexibilidad ante los imprevistos de hardware es una de las competencias más valiosas. Al comprobar que nuestras placas Heltec WiFi Kit 32 V3.2 carecen del módulo físico de radio LoRa de 868 MHz, tomamos una decisión de ingeniería: desplegar el protocolo ESP-NOW a 2.4 GHz.
En esta duodécima entrega completamos el ciclo integral de telemetría de BermejaSat:
- CanSat (Emisor): Heltec V3.2 lee el sensor BMP280, muestra todos los datos de telemetría en tamaño uniforme en su pantalla OLED integrada y emite tramas por radio inalámbrica.
- Estación Terrena Física (Receptor): Una segunda placa ESP32 capta las tramas en el aire y las inyecta por el puerto USB/Serie al ordenador.
- Caja Negra y Pantalla Terrena (Python): Un script en el ordenador recibe los datos, genera el archivo
.csvde registro y grafica la altitud, presión y temperatura en tiempo real.
📐 Arquitectura Global del Sistema
[ SENSOR BMP280 ]
│ (I2C: 41/42)
▼
[ HELTEC V3.2 (CanSat) ] ──(Radio ESP-NOW 2.4GHz)──► [ ESP32 RECEPTOR ]
- OLED con todos los datos (17/18) │ (USB / Serie)
- Emite Trama CSV ▼
[ PC ESTACIÓN TERRENA ]
- Grafica en Python
- Guarda en Caja Negra (.csv)
💻 1. Código Emisor: CanSat (Heltec V3.2 + BMP280 + OLED Completa)
Carga este código en la placa Heltec V3.2 que irá montada dentro del CanSat. Utiliza emisión por Broadcast (FF:FF:FF:FF:FF:FF), lo que significa que enviará la telemetría a cualquier receptor ESP-NOW que esté escuchando a su alrededor.
C++
#include <esp_now.h>
#include <WiFi.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_BMP280.h>
// --- PINES HELTEC V3.2 ---
#define OLED_SDA 17
#define OLED_SCL 18
#define OLED_RST 21
#define VEXT_PIN 36
#define BME_SDA 41
#define BME_SCL 42
// Dirección Broadcast (Envía a cualquier receptor en escucha)
uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
// Estructura de la Trama de Telemetría
typedef struct struct_message {
int id;
float altitud;
float presion;
float temperatura;
} struct_message;
struct_message datosTelemetria;
esp_now_peer_info_t peerInfo;
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
Adafruit_BMP280 bmp(&Wire1);
float presionSuelo = 1013.25;
int paqueteID = 0;
bool sensorOk = false;
void setup() {
Serial.begin(115200);
// 1. Encender Vext y resetear OLED
pinMode(VEXT_PIN, OUTPUT);
digitalWrite(VEXT_PIN, LOW);
delay(100);
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW); delay(20);
digitalWrite(OLED_RST, HIGH);
// 2. Iniciar buses I2C
Wire.begin(OLED_SDA, OLED_SCL);
Wire1.begin(BME_SDA, BME_SCL);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// 3. Configurar Radio ESP-NOW
WiFi.mode(WIFI_STA);
if (esp_now_init() != ESP_OK) {
Serial.println("[ERROR] Error al inicializar ESP-NOW");
return;
}
memcpy(peerInfo.peer_addr, broadcastAddress, 6);
peerInfo.channel = 0;
peerInfo.encrypt = false;
if (esp_now_add_peer(&peerInfo) != ESP_OK){
Serial.println("[ERROR] Error al añadir Peer Broadcast");
return;
}
// 4. Inicializar BMP280
if (bmp.begin(0x76)) {
sensorOk = true;
presionSuelo = bmp.readPressure() / 100.0F;
Serial.println("[OK] CANSAT EMISOR LISTO");
} else {
Serial.println("[ERROR] BMP280 no detectado");
}
}
void loop() {
if (sensorOk) {
paqueteID++;
// Lecturas físicas
datosTelemetria.id = paqueteID;
datosTelemetria.presion = bmp.readPressure() / 100.0F;
datosTelemetria.temperatura = bmp.readTemperature();
datosTelemetria.altitud = bmp.readAltitude(presionSuelo);
// Enviar datos por aire vía ESP-NOW
esp_err_t result = esp_now_send(broadcastAddress, (uint8_t *) &datosTelemetria, sizeof(datosTelemetria));
// --- MOSTRAR TODOS LOS DATOS EN PANTALLA OLED (TAMAÑO UNIFORME 1) ---
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.println("BERMEJASAT - EMISOR");
display.drawLine(0, 10, 128, 10, SSD1306_WHITE);
display.setCursor(0, 15);
display.print("Altitud: ");
display.print(datosTelemetria.altitud, 1);
display.println(" m");
display.setCursor(0, 27);
display.print("Presion: ");
display.print(datosTelemetria.presion, 1);
display.println(" hPa");
display.setCursor(0, 39);
display.print("Temp: ");
display.print(datosTelemetria.temperatura, 1);
display.println(" C");
display.setCursor(0, 51);
display.print("Estado TX: ");
if (result == ESP_OK) display.println("ENVIADO");
else display.println("ERROR");
display.display();
}
delay(1000);
}
📻 2. Código Receptor: Estación Terrena Hardware (Cualquier ESP32)
Conecta una segunda placa ESP32 (o LILYGO) al ordenador mediante USB. Carga este programa receptor; su única función es “escuchar” las tramas en el aire e imprimirlas limpias por el puerto serie en formato CSV para que las lea Python.
C++
#include <esp_now.h>
#include <WiFi.h>
// La estructura DEBE ser idéntica a la del Emisor
typedef struct struct_message {
int id;
float altitud;
float presion;
float temperatura;
} struct_message;
struct_message datosRecibidos;
// Callback que se ejecuta automáticamente cuando llega un paquete por el aire
void OnDataRecv(const esp_now_recv_info * info, const uint8_t *incomingData, int len) {
memcpy(&datosRecibidos, incomingData, sizeof(datosRecibidos));
// TRAMA CSV LIMPIA PARA PYTHON: ID,Altitud,Presion,Temperatura
Serial.print(datosRecibidos.id);
Serial.print(",");
Serial.print(datosRecibidos.altitud, 2);
Serial.print(",");
Serial.print(datosRecibidos.presion, 2);
Serial.print(",");
Serial.println(datosRecibidos.temperatura, 2);
}
void setup() {
Serial.begin(115200);
// Poner WiFi en modo Estación
WiFi.mode(WIFI_STA);
// Inicializar ESP-NOW
if (esp_now_init() != ESP_OK) {
Serial.println("Error al inicializar ESP-NOW en Receptor");
return;
}
// Registrar callback de recepción
esp_now_register_recv_cb(OnDataRecv);
}
void loop() {
// El receptor trabaja por interrupción (OnDataRecv), no necesita nada en loop
}
🐍 3. Código Python: Estación Terrena y Gráficas en Tiempo Real
Ejecuta este programa en el PC. Se conecta al puerto COM del Receptor, guarda los datos en una Caja Negra (caja_negra_bermejasat.csv) y genera una gráfica dinámica de la altitud y la presión.
(Instalar librerías previas en el terminal: pip install pyserial matplotlib)
Python
import serial
import time
import csv
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
# --- CONFIGURACIÓN DEL PUERTO SERIE ---
PUERTO_SERIE = 'COM22' # Ajustar al puerto COM del RECEPTOR
BAUDIOS = 115200
# Archivo de Caja Negra
ARCHIVO_CSV = 'caja_negra_bermejasat.csv'
# Estructuras de datos para las gráficas
lista_id = []
lista_altitud = []
lista_presion = []
lista_temp = []
# Abrir o crear el archivo CSV y escribir cabecera
with open(ARCHIVO_CSV, mode='w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['ID', 'Altitud_m', 'Presion_hPa', 'Temperatura_C', 'Timestamp'])
# Conectar al puerto Serie
try:
ser = serial.Serial(PUERTO_SERIE, BAUDIOS, timeout=1)
time.sleep(2)
print(f"[OK] Estación Terrena conectada a {PUERTO_SERIE}")
except Exception as e:
print(f"[ERROR] No se pudo abrir el puerto {PUERTO_SERIE}: {e}")
exit()
# --- CONFIGURACIÓN DE LA GRÁFICA MATPLOTLIB ---
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
fig.suptitle('BERMEJASAT - TELEMETRÍA EN TIEMPO REAL (ESP-NOW)', fontsize=14, fontweight='bold')
def actualizar_grafica(i):
if ser.in_waiting > 0:
try:
linea = ser.readline().decode('utf-8').strip()
partes = linea.split(',')
if len(partes) == 4:
pkt_id = int(partes[0])
altitud = float(partes[1])
presion = float(partes[2])
temperatura = float(partes[3])
timestamp = time.strftime('%H:%M:%S')
# Guardar en memoria
lista_id.append(pkt_id)
lista_altitud.append(altitud)
lista_presion.append(presion)
lista_temp.append(temperatura)
# Mantener los últimos 30 puntos
ids = lista_id[-30:]
alts = lista_altitud[-30:]
press = lista_presion[-30:]
# Guardar en Caja Negra CSV
with open(ARCHIVO_CSV, mode='a', newline='') as f:
writer = csv.writer(f)
writer.writerow([pkt_id, altitud, presion, temperatura, timestamp])
print(f"[{timestamp}] Pkt #{pkt_id} | Alt: {altitud} m | P: {presion} hPa | T: {temperatura} °C")
# Gráfica 1: Altitud
ax1.clear()
ax1.plot(ids, alts, color='red', marker='o', linewidth=2)
ax1.set_title('Perfil de Altitud (m)')
ax1.set_ylabel('Altitud (m)')
ax1.grid(True)
# Gráfica 2: Presión
ax2.clear()
ax2.plot(ids, press, color='blue', marker='s', linewidth=2)
ax2.set_title('Presión Atmosférica (hPa)')
ax2.set_xlabel('Paquete ID')
ax2.set_ylabel('Presión (hPa)')
ax2.grid(True)
except Exception as e:
pass
ani = FuncAnimation(fig, actualizar_grafica, interval=500)
plt.tight_layout()
plt.show()
Etiqueta:BermejaSat, BMP280, cansat, ESA, ESP-NOW, Estación Terrena, Heltec V3.2, ies monterroso, matplotlib, montesteam, python, Telemetría


