Herramientas del ecosistema: PlatformIO, Home Assistant, Grafana y dashboards de telemetria
Herramientas del ecosistema: PlatformIO, Home Assistant, Grafana y dashboards de telemetria
En el capitulo 10 terminamos con un ESP32 hablando I2C con un puñado de sensores: leíamos temperatura, presión y luz desde el bus, y el dato quedaba imprimiéndose en la consola serie. Ese es el punto en el que casi todos los tutoriales se detienen, y también el punto exacto donde empieza la parte difícil. Un número que aparece en un terminal y desaparece cuando cierras la sesión no es telemetría: es una prueba de que el cable está bien conectado.
Este capítulo cubre el tramo que va desde ese número volátil hasta un sistema que compila y carga firmware de forma reproducible, transporta lecturas por la red, las persiste, las grafica, y avisa cuando algo se sale de rango. Son cuatro herramientas y una idea. Las herramientas son PlatformIO (entorno de desarrollo para embebidos), Home Assistant (automatización local y broker de estado), Grafana (visualización sobre series temporales) y las plataformas IoT integradas como ThingsBoard y OpenRemote. La idea es que cada una resuelve una capa distinta de un mismo problema, y que elegir mal la capa es el error más caro del proyecto.
Vamos a montar la cadena completa con Docker, a publicar datos reales desde un ESP32 corriendo Elixir sobre AtomVM, y a construir dashboards que no sean solo bonitos sino accionables.
La cadena de telemetria: cinco capas, cinco decisiones
Antes de instalar nada conviene entender el mapa. Todo sistema de telemetría, desde un termómetro casero hasta una planta industrial, tiene las mismas cinco capas. Cambia la escala, no la estructura.
flowchart LR
subgraph ADQ["1. Adquisicion"]
S["Sensor I2C o analogico<br/>BME280, DHT11, INA219"]
end
subgraph FW["2. Firmware"]
MCU["ESP32<br/>AtomVM / ESP-IDF / Arduino"]
end
subgraph TR["3. Transporte"]
MQ["MQTT sobre TCP"]
RT["HTTP REST"]
end
subgraph ALM["4. Almacenamiento"]
HA["Home Assistant<br/>recorder y estado"]
PR["Prometheus / InfluxDB"]
SQ["SQLite / Postgres"]
end
subgraph VIS["5. Visualizacion y accion"]
GR["Grafana<br/>paneles y alertas"]
AU["Automatizaciones HA"]
end
S -->|"bus fisico"| MCU
MCU --> MQ & RT
MQ --> HA
RT --> HA & SQ
HA -->|"/api/prometheus"| PR
PR --> GR
SQ --> GR
HA --> AU
Cada flecha del diagrama es una decisión de diseño con consecuencias:
| Capa | Pregunta que resuelve | Si eliges mal |
|---|---|---|
| Adquisición | ¿Qué precisión y qué frecuencia de muestreo necesito? | Datos ruidosos o consumo de batería absurdo |
| Firmware | ¿Bare metal, RTOS o máquina virtual? | Reconexiones frágiles, watchdogs disparándose |
| Transporte | ¿Push o pull? ¿Con o sin broker? | El dispositivo se cae y nadie se entera |
| Almacenamiento | ¿Cuánto histórico necesito y a qué resolución? | Disco lleno en tres semanas o datos que no sirven |
| Visualización | ¿Quién mira esto y qué decisión toma? | Dashboards preciosos que nadie abre |
El resto del capítulo baja a cada capa con herramientas concretas.
PlatformIO: un entorno para todas las placas
El problema que resuelve
Programar un microcontrolador exige tres cosas: un toolchain (compilador cruzado que genera binarios para la arquitectura del chip, no para tu PC), un framework (las bibliotecas base: Arduino, ESP-IDF, Zephyr, STM32Cube) y un flasher (la utilidad que escribe el binario en la memoria de la placa por USB o JTAG).
Históricamente cada fabricante entregaba su propio paquete de los tres. Microchip empujaba MPLAB X, STMicroelectronics empujaba STM32CubeIDE, Espressif empujaba idf.py con su toolchain xtensa, Nordic empujaba nRF Connect. Cada uno con su instalador, sus rutas, sus variables de entorno y su versión de GCC. Si tu proyecto tocaba dos familias de chips, tenías dos entornos completos peleándose por el PATH.
PlatformIO invierte esa relación. En vez de un IDE por fabricante, ofrece una capa de gestión de plataformas que se integra como extensión en el editor que ya usas. Declaras la placa en un archivo de texto y PlatformIO descarga, instala y aísla el toolchain correspondiente en su propio directorio.
flowchart TD
INI["platformio.ini<br/>declara board, platform, framework"]
RES["Resolutor de dependencias<br/>pio pkg"]
REG["Registro de PlatformIO<br/>plataformas, frameworks, librerias"]
CACHE["~/.platformio/<br/>toolchains y paquetes aislados"]
BUILD["SCons<br/>compilacion incremental"]
BIN[".pio/build/env/firmware.elf<br/>.pio/build/env/firmware.bin"]
UP["Uploader<br/>esptool, dfu-util, openocd"]
HW["Placa fisica"]
SIM["Wokwi<br/>simulador"]
INI --> RES
RES -->|"descarga lo que falta"| REG
REG --> CACHE
CACHE --> BUILD
INI --> BUILD
BUILD --> BIN
BIN --> UP
UP -->|"USB / JTAG"| HW
BIN -->|"wokwi.toml + diagram.json"| SIM
La consecuencia práctica: un compañero de equipo clona el repositorio, ejecuta pio run y obtiene exactamente el mismo binario que tú, sin instalar nada a mano y sin importar si usa Linux, macOS o Windows.
Instalacion
PlatformIO tiene dos caras: el núcleo (pio, escrito en Python) y la extensión de editor. El núcleo es lo que realmente hace el trabajo; la extensión solo lo envuelve en botones.
# Nucleo standalone, independiente del editor
python3 -m venv ~/.platformio-venv
~/.platformio-venv/bin/pip install platformio
sudo ln -s ~/.platformio-venv/bin/pio /usr/local/bin/pio
pio --version && pio system info
Para VSCode basta instalar la extensión oficial platformio.platformio-ide, que instala el núcleo automáticamente. Emacs y Vim se integran vía compile y wrappers, porque toda la funcionalidad está disponible como comandos de terminal.
En Linux hace falta un paso extra que causa la mitad de los problemas de “no encuentro el puerto”: las reglas udev que dan permiso al usuario sobre los adaptadores USB-serie.
curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/system/99-platformio-udev.rules \
| sudo tee /etc/udev/rules.d/99-platformio-udev.rules
sudo udevadm control --reload-rules && sudo udevadm trigger
sudo usermod -aG dialout,plugdev $USER
# cerrar sesion y volver a entrar para que el grupo tome efecto
Estructura de un proyecto
Un proyecto de PlatformIO tiene una convención de directorios fija. Respetarla evita configuración manual.
pio project init --board esp32dev --project-dir ~/proyectos/telemetria
cd ~/proyectos/telemetria
Eso genera cinco directorios con roles fijos: src/ para el código fuente principal, include/ para las cabeceras públicas, lib/ para bibliotecas privadas del proyecto, test/ para las pruebas unitarias, y .pio/ para los artefactos de compilación, que nunca se versiona. En la raíz queda platformio.ini.
Regla útil: todo lo que va en lib/ se compila como biblioteca estática independiente y solo se enlaza si alguien la incluye. Eso hace que dividir el firmware en módulos sea barato.
El archivo platformio.ini
Es el corazón del proyecto. Formato INI, con secciones por entorno de compilación.
; platformio.ini
[platformio]
default_envs = esp32_produccion
; Opciones compartidas por todos los entornos.
; El prefijo "env" sin nombre actua como base heredable.
[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time
lib_deps =
adafruit/Adafruit BME280 Library @ ^2.2.4
knolleary/PubSubClient @ ^2.8
build_flags =
-DCORE_DEBUG_LEVEL=1
-DMQTT_MAX_PACKET_SIZE=512
[env:esp32_produccion]
board = esp32dev
build_flags =
${env.build_flags}
-DWIFI_SSID=\"casa\"
-DMQTT_HOST=\"192.168.1.14\"
board_build.partitions = min_spiffs.csv
; Misma logica compilada para otra familia de chip
[env:esp32c3]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
Puntos que conviene interiorizar:
platformes la familia de silicio y su toolchain (espressif32,espressif8266,ststm32,atmelavr,raspberrypi,nordicnrf52).boardes el modelo concreto de placa. Define frecuencia de CPU, tamaño de flash, tamaño de RAM y esquema de particiones por defecto.frameworkes la API con la que programas encima. Para ESP32 puedes elegirarduinooespidf, y en algunos casos ambos a la vez conframework = arduino, espidf.lib_depsacepta nombres del registro, URLs de Git, rutas locales y rangos semver. Se resuelve al compilar, sin comandos manuales.- Herencia con
${env.clave}: evita duplicar bloques largos entre entornos. - Entornos múltiples: el mismo código fuente compilado para varias placas o varias configuraciones. Es la forma correcta de manejar “versión de desarrollo” y “versión de campo”.
Comandos que vas a usar todos los dias
# Compilar el entorno por defecto
pio run
# Compilar un entorno concreto
pio run -e esp32c3
# Compilar, cargar y abrir el monitor serie en una sola pasada
pio run -t upload -t monitor
# Solo monitor, eligiendo puerto y velocidad
pio device monitor --port /dev/ttyUSB0 --baud 115200
# Listar puertos serie detectados
pio device list
# Limpiar artefactos de compilacion
pio run -t clean
# Borrar la flash entera (util cuando el firmware anterior dejo basura en NVS)
pio run -t erase
# Analisis estatico, pruebas en la placa real y actualizacion de paquetes
pio check
pio test -e esp32_produccion
pio pkg update
El monitor serie merece una mención especial. monitor_filters = esp32_exception_decoder hace que cuando el ESP32 entra en pánico y escupe un volcado de direcciones hexadecimales, PlatformIO las traduce automáticamente a nombres de función y números de línea usando el .elf que acaba de compilar. Sin ese filtro, un crash es un muro de números; con él, es un stack trace legible.
Simulacion con Wokwi
Wokwi simula el silicio del ESP32, del RP2040 y de varias placas AVR con suficiente fidelidad como para depurar lógica de firmware sin tener la placa delante. Su extensión para VSCode toma el binario que acaba de producir PlatformIO y lo ejecuta en un circuito virtual.
Necesitas dos archivos en la raíz del proyecto:
# wokwi.toml
[wokwi]
version = 1
firmware = '.pio/build/esp32_produccion/firmware.bin'
elf = '.pio/build/esp32_produccion/firmware.elf'
{
"version": 1,
"parts": [
{ "type": "board-esp32-devkit-c-v4", "id": "esp", "top": 0, "left": 0, "attrs": {} },
{ "type": "wokwi-dht22", "id": "dht1", "top": -60, "left": 180, "attrs": {} }
],
"connections": [
[ "esp:TX0", "$serialMonitor:RX", "", [] ],
[ "esp:RX0", "$serialMonitor:TX", "", [] ],
[ "dht1:VCC", "esp:3V3", "red", [] ],
[ "dht1:GND", "esp:GND.1", "black", [] ],
[ "dht1:SDA", "esp:21", "green", [] ]
]
}
El archivo diagram.json describe el circuito: componentes en parts y cableado en connections, donde cada conexión es [origen, destino, color, ruta]. Con ambos archivos presentes, el comando Wokwi: Start Simulator de la paleta de VSCode arranca la simulación con el firmware recién compilado.
Lo que Wokwi simula bien: GPIO, temporizadores, I2C, SPI, UART, WiFi contra internet real a través de un gateway virtual. Lo que no simula: características eléctricas reales, caídas de tensión, ruido, consumo, y el comportamiento de sensores baratos fuera de especificación. Es una herramienta de lógica, no de electrónica.
PlatformIO frente a las alternativas
| Criterio | PlatformIO | Arduino IDE 2.x | ESP-IDF (idf.py) | Zephyr (west) |
|---|---|---|---|---|
| Placas soportadas | Más de 1500, multi-fabricante | Amplio vía gestores de placas | Solo Espressif | Muy amplio, orientado a RTOS |
| Configuración | Archivo platformio.ini versionable | GUI, estado global del IDE | sdkconfig + Kconfig | prj.conf + devicetree |
| Reproducibilidad | Alta: dependencias fijadas por semver | Baja: depende de lo instalado | Alta si se fija la versión del IDF | Alta |
| Curva de aprendizaje | Media | Baja | Alta | Muy alta |
| Acceso a APIs de bajo nivel | Total si usas framework = espidf | Limitado | Total | Total |
| Integración con CI | Nativa, todo por CLI | Incómoda | Buena | Buena |
| Depuración con breakpoints | Sí, vía debug_tool y OpenOCD | Limitada | Sí | Sí |
| Simulación integrada | Wokwi vía extensión | No | QEMU parcial | Sí, native_sim |
Para un curso de robótica e IoT donde vas a tocar ESP32, quizá un RP2040 y quizá un AVR, PlatformIO es la elección que minimiza fricción. Para un producto comercial que vive y muere sobre ESP32 y necesita cada byte de RAM, ESP-IDF puro con idf.py da más control sobre sdkconfig.
Un detalle importante para este curso: AtomVM no se compila con PlatformIO. AtomVM tiene su propio flujo basado en mix y ExAtomVM, que genera un .avm y lo escribe en una partición de la flash. PlatformIO entra en juego cuando construyes el propio runtime de AtomVM desde fuentes, o cuando conviven módulos en C con el firmware Elixir.
Home Assistant: el cerebro local
Que es y por que importa
Home Assistant es una plataforma de automatización que corre en tu red, no en la nube de un fabricante. Su premisa es directa: cada dispositivo se representa como una entidad con un estado y unos atributos, y todo lo demás —tarjetas, gráficos, automatizaciones, notificaciones— opera sobre ese modelo uniforme.
Esa uniformidad es lo valioso. Un sensor Zigbee de una marca, un ESP32 casero hablando MQTT y un termostato con API REST propietaria terminan todos como entidades con la misma forma: sensor.temperatura_pieza con estado 21.4 y unidad °C. Una automatización escrita contra esa entidad no sabe ni le importa de dónde vino el dato.
flowchart TD
subgraph FUENTES["Fuentes de datos"]
Z["Zigbee / Z-Wave<br/>via coordinador USB"]
M["ESP32 propio<br/>MQTT o REST"]
E["ESPHome<br/>API nativa"]
end
subgraph HA["Home Assistant Core"]
INT["Integraciones"]
BUS["Bus de eventos<br/>state_changed"]
ST["Machine State<br/>entidades y atributos"]
REC["Recorder<br/>SQLite o Postgres"]
AUT["Motor de automatizaciones<br/>trigger / condition / action"]
end
subgraph SALIDAS["Consumidores"]
LOV["Interfaz Lovelace"]
PROM["Endpoint /api/prometheus"]
NOT["Notificaciones y actuadores"]
end
Z & M & E --> INT
INT --> BUS
BUS --> ST & AUT
ST --> REC & LOV & PROM
AUT --> NOT
Los cuatro metodos de instalacion
| Método | Qué incluye | Add-ons | Actualización | Cuándo usarlo |
|---|---|---|---|---|
| Home Assistant OS | Sistema operativo completo + supervisor + core | Sí | Desde la interfaz | Raspberry Pi o mini PC dedicado |
| Home Assistant Container | Solo el core, en Docker | No | docker pull manual | Servidor que ya corre otros contenedores |
| Home Assistant Supervised | Core + supervisor sobre Debian propio | Sí | Desde la interfaz | Casos avanzados con hardware específico |
| Home Assistant Core | Paquete Python en un venv | No | pip install --upgrade | Desarrollo del propio Home Assistant |
Para el escenario de este capítulo —un servidor casero donde también van a vivir Mosquitto, Prometheus y Grafana— la variante Container es la que encaja: todo el stack se declara en un docker-compose.yml versionable.
Levantar el stack con Docker
mkdir -p ~/services/{home-assistant,prometheus,grafana}
mkdir -p ~/services/mosquitto/{config,data,log}
Configuración mínima del broker MQTT:
# ~/services/mosquitto/config/mosquitto.conf
listener 1883 0.0.0.0
allow_anonymous false
password_file /mosquitto/config/passwd
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
log_type error
log_type warning
log_type notice
La fuente original usa allow_anonymous true para simplificar, y eso funciona en una red aislada de laboratorio. En cuanto el broker sea alcanzable desde cualquier otro segmento de red, autenticación mínima:
# -c crea el archivo desde cero; omitelo para anadir usuarios al existente
docker run --rm -v ~/services/mosquitto/config:/mosquitto/config eclipse-mosquitto \
mosquitto_passwd -c -b /mosquitto/config/passwd esp32 clave_del_esp32
docker run --rm -v ~/services/mosquitto/config:/mosquitto/config eclipse-mosquitto \
mosquitto_passwd -b /mosquitto/config/passwd hass clave_de_hass
Y ahora el stack completo:
# ~/services/docker-compose.yml
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
restart: unless-stopped
privileged: true
network_mode: host
environment:
- TZ=America/Santiago
volumes:
- ~/services/home-assistant:/config
- /run/dbus:/run/dbus:ro
mosquitto:
image: eclipse-mosquitto:2
restart: unless-stopped
network_mode: host
volumes:
- ~/services/mosquitto/config:/mosquitto/config
- ~/services/mosquitto/data:/mosquitto/data
- ~/services/mosquitto/log:/mosquitto/log
prometheus:
image: prom/prometheus:latest
restart: unless-stopped
network_mode: host
volumes:
- ~/services/prometheus:/etc/prometheus
- prometheus_data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.retention.time=90d"
grafana:
image: grafana/grafana-oss:latest
restart: unless-stopped
network_mode: host
environment:
- GF_INSTALL_PLUGINS=frser-sqlite-datasource
- GF_SECURITY_ADMIN_PASSWORD=cambiame
volumes:
- grafana_data:/var/lib/grafana
- ~/services/grafana/provisioning:/etc/grafana/provisioning
volumes:
prometheus_data:
grafana_data:
cd ~/services
docker compose up -d
docker compose logs -f homeassistant
network_mode: host aparece en los cuatro servicios por una razón concreta: Home Assistant necesita ver el tráfico multicast de la red local para descubrir dispositivos (mDNS, SSDP, DHCP), y eso no atraviesa el NAT de una red bridge de Docker. El precio es que los cuatro servicios comparten el espacio de puertos del anfitrión: 8123 para Home Assistant, 1883 para Mosquitto, 9090 para Prometheus, 3000 para Grafana. Si alguno choca, hay que cambiarlo.
privileged: true y el montaje de /run/dbus solo hacen falta si vas a conectar dongles USB (Zigbee, Z-Wave, Bluetooth). Sin ellos, Home Assistant funciona igual para todo lo que llegue por red.
Con el stack arriba, abre http://<ip-del-servidor>:8123, crea la cuenta de administrador y llegas al panel vacío.
Integracion REST: cuando el ESP32 es servidor
El patrón más simple es que el microcontrolador exponga un endpoint HTTP y Home Assistant lo consulte cada cierto tiempo. Es pull: el servidor decide cuándo preguntar.
Del lado del ESP32, con Elixir sobre AtomVM y atomvm_lib:
# lib/telemetria_http.ex
defmodule TelemetriaHTTP do
@moduledoc "Expone las lecturas de un DHT11 en un endpoint HTTP."
@behaviour :httpd_api_handler
@puerto 8080
@pin_dht 21
def start do
{:ok, dht} = :dht.start(%{pin: @pin_dht, device: :dht_11})
config = %{
@puerto => [
server_name: "telemetria",
server_root: ".",
document_root: ".",
modules: [{:httpd_api_handler, [{["temp"], __MODULE__, dht}]}]
]
}
{:ok, _pid} = :httpd.start(config)
dormir_para_siempre()
end
@impl :httpd_api_handler
def handle_api_request(:get, ["temp"], _http_request, dht) do
case tomar_lectura(dht) do
{:ok, temperatura, humedad} ->
{:close,
%{
"temperature" => :erlang.float_to_binary(temperatura, decimals: 1),
"humidity" => :erlang.float_to_binary(humedad, decimals: 1),
"status" => "ok"
}}
:error ->
{:close, %{"status" => "error"}}
end
end
def handle_api_request(_metodo, _ruta, _req, _estado) do
{:close, %{"status" => "not_found"}}
end
defp tomar_lectura(dht) do
case :dht.measure(dht) do
{:ok, {t_ent, t_dec, h_ent, h_dec}} -> {:ok, t_ent + t_dec / 10, h_ent + h_dec / 10}
_otro -> :error
end
end
defp dormir_para_siempre do
receive do
_ -> dormir_para_siempre()
end
end
end
La forma exacta del mapa de configuración de :httpd y la tupla que devuelve :dht.measure/1 dependen de la versión de atomvm_lib que tengas fijada en mix.exs; conviene verificarlas contra el repositorio antes de dar por buena la compilación. El concepto no cambia: un proceso HTTP escucha, un handler lee el sensor y devuelve un mapa que se serializa a JSON.
Dependencias típicas:
# mix.exs
defp deps do
[
{:exatomvm, git: "https://github.com/atomvm/ExAtomVM/"},
{:avm_scene, git: "https://github.com/atomvm/avm_scene/"},
{:atomvm_lib, git: "https://github.com/atomvm/atomvm_lib/"},
{:mjson, git: "https://github.com/mbj4668/mjson/"}
]
end
Del lado de Home Assistant, en ~/services/home-assistant/configuration.yaml:
rest:
- resource: http://192.168.1.10:8080/temp
scan_interval: 30
timeout: 10
sensor:
- name: "ESP32 Temperatura"
unique_id: esp32_temperatura_rest
value_template: "{{ value_json.temperature | float }}"
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
- name: "ESP32 Humedad"
unique_id: esp32_humedad_rest
value_template: "{{ value_json.humidity | float }}"
unit_of_measurement: "%"
device_class: humidity
state_class: measurement
Un solo bloque resource puede alimentar varios sensores: Home Assistant hace una única petición HTTP por scan_interval y aplica cada value_template sobre la misma respuesta.
Tres campos que la gente omite y luego echa de menos:
unique_id: sin él, la entidad no aparece en el registro y no puedes renombrarla ni asignarla a un área desde la interfaz.device_class: le dice a Home Assistant qué representa el número. Determina el icono, el formato y qué automatizaciones de plantilla tienen sentido.state_class: measurement: habilita las estadísticas de largo plazo. Sin él,recorderpurga los datos a los diez días por defecto y pierdes el histórico.
Aplicar los cambios:
# Validar la configuracion antes de reiniciar
docker exec homeassistant python -m homeassistant --script check_config --config /config
docker restart homeassistant
Integracion MQTT: cuando el ESP32 es cliente
REST tiene un límite estructural: el servidor tiene que saber la dirección IP del dispositivo, el dispositivo tiene que estar despierto y accesible en el momento exacto de la consulta, y no hay forma de que el dispositivo avise de un evento urgente sin esperar al siguiente scan_interval.
MQTT invierte el flujo. El dispositivo se conecta a un broker y publica en un topic cuando tiene algo que decir. Home Assistant se suscribe al topic y recibe el dato en cuanto llega. Es push, y encaja mucho mejor con dispositivos que duermen, cambian de IP o viven detrás de un NAT.
sequenceDiagram
participant E as ESP32 con AtomVM
participant B as Broker Mosquitto
participant H as Home Assistant
participant G as Grafana
E->>B: CONNECT client_id=esp32-sala, LWT casa/sala/estado = offline
B-->>E: CONNACK
E->>B: PUBLISH retain homeassistant/device/esp32sala/config
B->>H: entrega config de descubrimiento
H->>H: crea sensor.esp32_sala_temperatura y ..._humedad
E->>B: PUBLISH retain casa/sala/estado = "online"
B->>H: estado online
loop cada 30 segundos
E->>E: leer DHT11 por GPIO
E->>B: PUBLISH casa/sala/clima {"temperature":21.4,"humidity":58.2}
B->>H: entrega payload
H->>H: actualiza entidades y recorder persiste el cambio
end
G->>H: scrape /api/prometheus via Prometheus
H-->>G: hass_sensor_temperature_celsius 21.4
Note over E,B: si el ESP32 se cae, el broker publica el Last Will
B->>H: casa/sala/estado = "offline"
H->>H: entidad marcada como no disponible
El código del ESP32:
# lib/telemetria_mqtt.ex
defmodule TelemetriaMQTT do
@moduledoc """
Publica lecturas de un DHT11 en un broker MQTT con formato JSON,
incluyendo el mensaje de descubrimiento de Home Assistant.
"""
@pin_dht 21
@intervalo_ms 30_000
@broker "mqtt://192.168.1.14:1883"
@usuario "esp32"
@clave "clave_del_esp32"
@topic_estado "casa/sala/estado"
@topic_datos "casa/sala/clima"
@topic_descubrimiento "homeassistant/device/esp32sala/config"
def start do
{:ok, dht} = :dht.start(%{pin: @pin_dht, device: :dht_11})
config = %{
url: @broker,
username: @usuario,
password: @clave,
client_id: "esp32-sala",
will: %{topic: @topic_estado, message: "offline", qos: 1, retain: true},
connected_handler: &al_conectar/1
}
{:ok, mqtt} = :mqtt_client.start(config)
bucle(mqtt, dht)
end
defp al_conectar(mqtt) do
:mqtt_client.publish(mqtt, @topic_descubrimiento, descubrimiento(), qos: 1, retain: true)
:mqtt_client.publish(mqtt, @topic_estado, "online", qos: 1, retain: true)
:ok
end
defp bucle(mqtt, dht) do
case :dht.measure(dht) do
{:ok, {t_ent, t_dec, h_ent, h_dec}} ->
payload = :json.encode(%{"temperature" => t_ent + t_dec / 10, "humidity" => h_ent + h_dec / 10})
:mqtt_client.publish(mqtt, @topic_datos, payload, qos: 0, retain: false)
otro ->
:io.format(~c"lectura fallida: ~p~n", [otro])
end
Process.sleep(@intervalo_ms)
bucle(mqtt, dht)
end
defp descubrimiento do
:json.encode(%{
"dev" => %{"ids" => "esp32sala", "name" => "ESP32 Sala", "mf" => "Artiko"},
"o" => %{"name" => "telemetria-elixir"},
"avty_t" => @topic_estado,
"cmps" => %{
"temp" => %{
"p" => "sensor",
"device_class" => "temperature",
"unit_of_measurement" => "°C",
"state_class" => "measurement",
"value_template" => "{{ value_json.temperature | round(1) }}",
"unique_id" => "esp32sala_temp"
},
"hum" => %{
"p" => "sensor",
"device_class" => "humidity",
"unit_of_measurement" => "%",
"value_template" => "{{ value_json.humidity | round(1) }}",
"unique_id" => "esp32sala_hum"
}
},
"stat_t" => @topic_datos,
"qos" => 0
})
end
end
Este ejemplo usa el descubrimiento automático de MQTT, que es la diferencia entre configurar diez sensores a mano en YAML y no configurar ninguno. El dispositivo publica, con la bandera retain activada, un mensaje en un topic bajo el prefijo homeassistant/. Home Assistant está suscrito a ese prefijo, lee el mensaje y crea las entidades solo. Como el mensaje es retenido, el broker se lo vuelve a entregar a Home Assistant cada vez que este reinicia, sin que el ESP32 tenga que republicarlo.
El formato mostrado es el de descubrimiento por dispositivo: un único mensaje declara el dispositivo y todos sus componentes en cmps. Es más compacto que el formato clásico, que exige un mensaje por entidad publicado en homeassistant/sensor/<node_id>/<object_id>/config con las claves completas name, state_topic, availability_topic, value_template, unit_of_measurement, device_class, state_class y un bloque device con identifiers. Ambos formatos siguen siendo válidos.
También se puede declarar cada entidad a mano en configuration.yaml, bajo la clave mqtt: con una lista sensor:, usando las mismas claves en formato YAML: name, unique_id, state_topic, availability_topic, payload_available, payload_not_available, value_template, unit_of_measurement, device_class y state_class. Es la vía obligada cuando el firmware es de terceros y no publica su propia configuración.
Para depurar MQTT, mosquitto_sub es insustituible:
# Ver todo lo que pasa por el broker; cambiar '#' por 'casa/sala/clima'
# o por 'homeassistant/#' para acotar a un topic concreto
mosquitto_sub -h 192.168.1.14 -u hass -P clave_de_hass -t '#' -v
# Publicar una lectura falsa para probar el dashboard sin el ESP32
mosquitto_pub -h 192.168.1.14 -u hass -P clave_de_hass \
-t 'casa/sala/clima' -m '{"temperature": 23.7, "humidity": 61.0}'
# Borrar un mensaje retenido que quedo mal
mosquitto_pub -h 192.168.1.14 -u hass -P clave_de_hass \
-t 'homeassistant/device/esp32sala/config' -r -n
Ese último comando resuelve uno de los problemas más frustrantes del descubrimiento: si publicaste un mensaje de configuración con un error y luego lo corriges, el mensaje viejo sigue retenido en el broker. Publicar un mensaje vacío con retain lo borra.
REST frente a MQTT
| Aspecto | REST (pull) | MQTT (push) |
|---|---|---|
| Quién inicia | Home Assistant consulta | El dispositivo publica |
| Latencia de un evento | Hasta un scan_interval completo | Milisegundos |
| Dispositivo con IP dinámica | Se rompe | Funciona, el dispositivo busca al broker |
| Dispositivo que duerme | Incompatible | Compatible, publica al despertar |
| Detección de caída | Timeout de la petición | Last Will and Testament inmediato |
| Consumo del dispositivo | Debe mantener servidor escuchando | Puede dormir entre publicaciones |
| Sobrecarga por mensaje | Cabeceras HTTP completas | 2 bytes de cabecera fija mínima |
| Infraestructura extra | Ninguna | Un broker |
| Depuración | curl | mosquitto_sub |
Regla práctica: REST para prototipos rápidos y dispositivos siempre alimentados en una red estable; MQTT para cualquier cosa que vaya a producción, que funcione con batería o que sea más de un dispositivo.
ESPHome: la tercera via
Vale mencionarlo porque aparece en toda conversación sobre ESP32 y Home Assistant. ESPHome genera el firmware completo a partir de un YAML declarativo: describes los sensores y los pines, y ESPHome compila, carga y mantiene la conexión con Home Assistant por su API nativa, incluyendo actualizaciones por aire.
# sala.yaml para ESPHome
esphome:
name: esp32-sala
esp32:
board: esp32dev
framework:
type: arduino
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
ota:
- platform: esphome
sensor:
- platform: dht
pin: GPIO21
model: DHT11
temperature:
name: "Temperatura Sala"
humidity:
name: "Humedad Sala"
update_interval: 30s
Es la ruta más rápida al resultado y probablemente la mejor si tu objetivo es domótica funcionando. La razón para no usarla en este curso es explícita: no aprendes nada sobre concurrencia, supervisión de procesos ni comportamiento del runtime, que es exactamente lo que Elixir sobre AtomVM enseña. ESPHome resuelve el problema; AtomVM te enseña el problema.
Grafana: la capa de visualizacion
Modelo mental
Grafana no almacena datos. Es una capa de consulta y presentación sobre bases que ya existen. Todo su modelo son cuatro conceptos:
flowchart LR
DS["Data source<br/>SQLite, Prometheus, InfluxDB"] --> Q["Query<br/>SQL, PromQL, Flux"]
V["Variables<br/>plantillas reutilizables"] --> Q
Q --> TR["Transformaciones<br/>join, rename, campos calculados"]
TR --> P["Panel<br/>time series, gauge, stat, table"]
P --> D["Dashboard<br/>coleccion de paneles"]
Q --> A["Regla de alerta<br/>condicion sobre una query"]
A --> CP["Contact point<br/>email, Telegram, webhook"]
Un panel es una query renderizada. Un dashboard es un conjunto de paneles que comparten un rango de tiempo y unas variables. Las alertas son queries que se evalúan periódicamente contra un umbral. No hay más.
Camino 1: SQLite alimentado desde Elixir
El camino más corto para ver un gráfico funcionando: un script de Elixir consulta el endpoint REST del ESP32 y escribe en SQLite; Grafana lee ese archivo.
# grafana.exs
Mix.install([:req, :exqlite])
defmodule Recolector do
@endpoint "http://192.168.1.10:8080/temp"
@intervalo_ms 5_000
def iniciar do
{:ok, conn} = Exqlite.Sqlite3.open("file:main.db")
:ok =
Exqlite.Sqlite3.execute(conn, """
create table if not exists readings (
id integer primary key, humidity real not null,
temperature real not null, time integer not null)
""")
:ok = Exqlite.Sqlite3.execute(conn, "create index if not exists idx_time on readings (time)")
bucle(conn)
end
defp bucle(conn) do
case leer_sensor() do
{:ok, t, h} ->
guardar(conn, t, h)
IO.puts("#{DateTime.utc_now()} -> #{t} C / #{h} %")
{:error, motivo} ->
IO.puts("lectura fallida: #{inspect(motivo)}")
end
Process.sleep(@intervalo_ms)
bucle(conn)
end
defp leer_sensor do
case Req.get(@endpoint, receive_timeout: 5_000, retry: false) do
{:ok, %{status: 200, body: %{"temperature" => t, "humidity" => h}}} ->
{:ok, a_float(t), a_float(h)}
{:ok, %{status: status}} -> {:error, {:http, status}}
{:error, excepcion} -> {:error, excepcion}
end
end
# El ESP32 puede devolver el numero como float o como binario con unidad
defp a_float(valor) when is_number(valor), do: valor * 1.0
defp a_float(valor) when is_binary(valor) do
case valor |> String.replace(~r/[^0-9.\-]/, "") |> Float.parse() do
{numero, _resto} -> numero
:error -> 0.0
end
end
defp guardar(conn, temperatura, humedad) do
sql = "insert into readings (humidity, temperature, time) values (?1, ?2, ?3)"
{:ok, sentencia} = Exqlite.Sqlite3.prepare(conn, sql)
:ok = Exqlite.Sqlite3.bind(sentencia, [humedad, temperatura, System.os_time(:second)])
:done = Exqlite.Sqlite3.step(conn, sentencia)
:ok = Exqlite.Sqlite3.release(conn, sentencia)
end
end
Recolector.iniciar()
elixir grafana.exs
Detalles que importan en ese script y que la versión ingenua omite:
Exqlite.Sqlite3.release/2: sin liberar la sentencia preparada, cada iteración deja un recurso abierto. En un bucle que corre indefinidamente eso es una fuga de memoria garantizada.- Índice sobre
time: sin él, cada consulta de Grafana que filtre por rango recorre la tabla entera. Con miles de filas da igual; con millones, no. receive_timeout: si el ESP32 se cuelga a media respuesta, el recolector se queda esperando indefinidamente y deja de registrar.- Timestamp en segundos Unix: es lo que espera el plugin de SQLite de Grafana en su modo de tiempo.
Configuración en Grafana:
- El contenedor ya trae el plugin gracias a
GF_INSTALL_PLUGINS=frser-sqlite-datasource. Si lo instalas a mano:docker exec grafana grafana-cli plugins install frser-sqlite-datasource && docker restart grafana. - Connections → Data sources → Add new data source → SQLite. En Path, la ruta absoluta del archivo dentro del contenedor. Si el archivo vive en el anfitrión, hay que montarlo como volumen.
- Save & test.
La query del panel:
SELECT
time AS "time",
temperature AS "Temperatura",
humidity AS "Humedad"
FROM readings
WHERE time BETWEEN $__unixEpochFrom() AND $__unixEpochTo()
ORDER BY time
Las macros $__unixEpochFrom() y $__unixEpochTo() se expanden al rango de tiempo seleccionado en el dashboard. Sin ellas, el panel carga la tabla completa cada vez y el selector de tiempo no hace nada.
Para agregar por intervalos y suavizar el ruido:
SELECT
(time / 300) * 300 AS "time",
AVG(temperature) AS "Media",
MIN(temperature) AS "Minima",
MAX(temperature) AS "Maxima"
FROM readings
WHERE time BETWEEN $__unixEpochFrom() AND $__unixEpochTo()
AND temperature BETWEEN -40 AND 85
GROUP BY (time / 300)
ORDER BY 1
La división entera por 300 agrupa en cubos de cinco minutos. El filtro BETWEEN -40 AND 85 descarta lecturas fuera del rango físico del sensor, que es la forma más simple de que un pico espurio no arruine la escala del gráfico.
Aprovisionar la fuente de datos por archivo, en vez de por interfaz, hace el stack reproducible:
# ~/services/grafana/provisioning/datasources/sqlite.yaml
apiVersion: 1
datasources:
- name: TelemetriaSQLite
type: frser-sqlite-datasource
access: proxy
uid: telemetria_sqlite
jsonData:
path: /var/lib/grafana/data/main.db
Camino 2: Prometheus leyendo Home Assistant
El camino de SQLite es didáctico pero no escala: un archivo, un escritor, sin retención automática, sin agregación. El camino serio pasa por una base de series temporales. Home Assistant expone sus estados en formato Prometheus con una sola línea de configuración.
flowchart LR
ESP["ESP32<br/>AtomVM"] -->|"MQTT publish"| MOS["Mosquitto :1883"]
MOS -->|"subscribe"| HASS["Home Assistant :8123"]
HASS -->|"expone"| EP["GET /api/prometheus<br/>formato de exposicion"]
PROM["Prometheus :9090"] -->|"scrape 60s + Bearer token"| EP
PROM -->|"retencion 90d"| TSDB[("TSDB local en disco")]
GRAF["Grafana :3000<br/>paneles y alertas"] -->|"PromQL"| PROM
Paso 1: generar el token de larga duración. En Home Assistant, clic en el nombre de usuario abajo a la izquierda, pestaña Security, sección Long-Lived Access Tokens, botón Create token. Se muestra una sola vez; si lo pierdes, hay que crear otro.
Paso 2: habilitar el endpoint. En configuration.yaml:
prometheus:
namespace: hass
filter:
include_domains: [sensor, binary_sensor, switch, climate]
exclude_entities: [sensor.date, sensor.time]
El filtro no es cosmético. Sin él, Home Assistant expone cada entidad que exista, incluidas decenas de sensores de diagnóstico del propio sistema, y Prometheus almacena todas. El bloque filter acepta include_domains, include_entities, exclude_domains y exclude_entities, y se evalúa en ese orden de especificidad.
Reinicia Home Assistant y comprueba el endpoint:
curl -s -H "Authorization: Bearer TU.TOKEN.LARGO" \
http://localhost:8123/api/prometheus | head -40
Deberías ver líneas del estilo:
# TYPE hass_sensor_temperature_celsius gauge
hass_sensor_temperature_celsius{domain="sensor",entity="sensor.temperatura_sala",friendly_name="Temperatura Sala"} 21.4
hass_sensor_humidity_percent{domain="sensor",entity="sensor.humedad_sala",friendly_name="Humedad Sala"} 58.2
Paso 3: configurar el scrape.
# ~/services/prometheus/prometheus.yml
global:
scrape_interval: 60s
evaluation_interval: 60s
scrape_configs:
- job_name: "hass"
scrape_interval: 60s
metrics_path: /api/prometheus
scheme: http
authorization:
type: Bearer
credentials: "TU.TOKEN.LARGO"
static_configs:
- targets: ["localhost:8123"]
labels:
instalacion: "casa"
targets: ["localhost:8123"] funciona porque ambos contenedores usan network_mode: host. Si Prometheus corriera en una red bridge, tendría que apuntar a la IP real del anfitrión o al nombre del servicio en la red de Docker.
docker restart prometheus
# El objetivo debe aparecer con health "up"
curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | head -40
Paso 4: Prometheus como fuente en Grafana. Connections → Data sources → Prometheus, URL http://localhost:9090, Save & test.
PromQL para telemetria
PromQL no es SQL. Opera sobre series identificadas por nombre de métrica y etiquetas, y devuelve vectores.
| Consulta | Qué devuelve |
|---|---|
hass_sensor_temperature_celsius | Todas las series de temperatura, valor actual |
hass_sensor_temperature_celsius{entity="sensor.temperatura_sala"} | Solo esa entidad |
hass_sensor_temperature_celsius{friendly_name=~"Temperatura.*"} | Todas las que coincidan con la expresión regular |
avg_over_time(hass_sensor_temperature_celsius[15m]) | Media móvil de 15 minutos, suaviza el ruido |
max_over_time(hass_sensor_temperature_celsius[24h]) | Máximo del último día |
delta(hass_sensor_temperature_celsius[1h]) | Cuánto cambió en la última hora |
deriv(hass_sensor_temperature_celsius[30m]) | Velocidad de cambio, grados por segundo |
hass_sensor_temperature_celsius - on(entity) hass_sensor_temperature_celsius offset 24h | Diferencia con el mismo momento de ayer |
count(up{job="hass"} == 1) | Cuántos objetivos responden |
absent(hass_sensor_temperature_celsius{entity="sensor.temperatura_sala"}) | Alerta si la serie desapareció |
predict_linear(hass_sensor_temperature_celsius[1h], 3600) | Extrapolación lineal a una hora vista |
Las funciones *_over_time esperan un selector de rango entre corchetes y solo funcionan sobre series de tipo gauge o counter con muestras dentro de ese rango. Si el scrape_interval es de 60 segundos y pides avg_over_time(...[30s]), obtendrás como mucho una muestra o ninguna.
Para detectar que un sensor dejó de reportar hay dos herramientas: la métrica implícita up{job="hass"}, que vale 1 cuando el scrape tuvo éxito y 0 cuando falló, y absent(), que detecta la desaparición de una serie concreta aunque el resto del endpoint siga respondiendo.
Diseno de dashboards que sirven
Un dashboard no es una colección de todo lo que se puede medir. Es la respuesta a una pregunta. Antes de añadir un panel, formula la pregunta que responde y a quién se la responde.
| Tipo de panel | Pregunta que responde | Cuándo usarlo |
|---|---|---|
| Time series | ¿Cómo evolucionó esto? | La lectura principal de cualquier telemetría |
| Stat | ¿Cuál es el valor ahora? | Números clave arriba del dashboard |
| Gauge | ¿Qué tan cerca del límite está? | Batería, ocupación, temperatura crítica |
| Bar gauge | ¿Cómo se comparan varias cosas? | Temperatura de varias habitaciones |
| Table | ¿Cuáles son los detalles concretos? | Listado de dispositivos y su último reporte |
| State timeline | ¿Cuándo estuvo en cada estado? | Encendido/apagado, online/offline |
| Heatmap | ¿Cómo se distribuyen los valores? | Patrones diarios o semanales |
| Histogram | ¿Cuál es la distribución? | Análisis de dispersión de un sensor |
Reglas de diseño que sostienen un dashboard en el tiempo:
- Una fila superior de
statcon lo esencial. Quien abre el dashboard debe saber en tres segundos si algo va mal. - Unidades siempre. En Panel options → Standard options → Unit elige la unidad real (
celsius,percent,volt,watt). Grafana formatea y escala solo. - Umbrales visuales. En Thresholds, define los valores que separan normal de anómalo. Un gauge sin umbrales no informa nada que el número no diga.
- Ejes fijos cuando el rango físico es conocido. Si el sensor mide entre 0 y 100 %, fija
minymax. Un eje automático hace que el ruido de medio punto parezca una crisis. - Variables para no duplicar dashboards. En Dashboard settings → Variables, crea una variable de tipo Query llamada
habitacioncon la consultalabel_values(hass_sensor_temperature_celsius, entity), y úsala en los paneles comohass_sensor_temperature_celsius{entity="$habitacion"}. Un solo dashboard sirve para todas las habitaciones, con un desplegable arriba. - Anotaciones para el contexto. Marcar en el gráfico cuándo se reinició el dispositivo o cuándo se cambió el sensor evita interpretar mal un escalón.
- Rango de tiempo por defecto sensato. Si tu
scrape_intervales de 60 segundos, un rango por defecto de 5 minutos muestra cinco puntos.Last 6 hoursoLast 24 hourssuele ser mejor punto de partida.
Los dashboards también se pueden aprovisionar como código, lo que los hace versionables:
# ~/services/grafana/provisioning/dashboards/telemetria.yaml
apiVersion: 1
providers:
- name: "telemetria"
orgId: 1
folder: "Telemetria"
type: file
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards/json
Se exporta un dashboard desde la interfaz con Share → Export → Save to file, se guarda el JSON en provisioning/dashboards/json/ y Grafana lo recarga solo.
Alertas: el paso de mirar a enterarse
Un dashboard solo funciona si alguien lo mira. Las alertas invierten eso: el sistema avisa.
stateDiagram-v2
[*] --> Normal
Normal --> Pendiente: la condicion se cumple<br/>en una evaluacion
Pendiente --> Normal: la condicion deja de cumplirse<br/>antes del pending period
Pendiente --> Disparada: la condicion sigue cumpliendose<br/>durante todo el pending period
Disparada --> Normal: la condicion deja de cumplirse
Disparada --> Disparada: reevaluacion, se mantiene
Normal --> SinDatos: la query no devuelve series
SinDatos --> Normal: vuelven los datos
SinDatos --> Disparada: si "No data" esta configurado como Alerting
note right of Pendiente
El pending period evita alertas
por un solo pico transitorio
end note
note right of Disparada
Se notifica al contact point
end note
Crear una alerta en Grafana: Alerting → Alert rules → New alert rule.
- Query A:
avg_over_time(hass_sensor_temperature_celsius{entity="sensor.temperatura_sala"}[10m]). - Expression B, tipo Reduce, función
Last, entrada A. - Expression C, tipo Threshold, entrada B, condición
IS ABOVE 30. - Evaluation: grupo evaluado cada
1m, con pending period de5m. - Configure labels and notifications: elegir el contact point.
El pending period es lo que separa una alerta útil de una que grita. Sin él, un pico de un segundo por interferencia eléctrica dispara la notificación. Con cinco minutos, la condición tiene que sostenerse.
La otra alerta imprescindible es la de ausencia de datos, que detecta que el dispositivo se cayó:
absent(hass_sensor_temperature_celsius{entity="sensor.temperatura_sala"})
Devuelve 1 cuando la serie no existe y vacío cuando sí existe. Un umbral IS ABOVE 0 sobre eso avisa cuando el ESP32 deja de reportar. Es la alerta que más veces salva un despliegue, porque un sensor mudo no dispara ninguna alerta de umbral: simplemente deja de haber datos y todo parece tranquilo.
Home Assistant también automatiza, y para acciones locales inmediatas es mejor sitio que Grafana:
# automations.yaml
- id: ventilador_por_temperatura
alias: "Encender ventilador si la sala supera 28 grados"
trigger:
- platform: numeric_state
entity_id: sensor.temperatura_sala
above: 28
for:
minutes: 5
condition:
- condition: state
entity_id: binary_sensor.alguien_en_casa
state: "on"
action:
- service: switch.turn_on
target:
entity_id: switch.ventilador_sala
- service: notify.persistent_notification
data:
message: "Ventilador encendido: {{ states('sensor.temperatura_sala') }} C"
mode: single
La misma estructura, con un disparador state hacia unavailable y un for de diez minutos, sirve para avisar cuando el ESP32 deja de reportar sin salir de Home Assistant.
División de responsabilidades razonable: Home Assistant automatiza y actúa (encender, apagar, avisar de inmediato); Grafana analiza y alerta sobre tendencias (medias móviles, comparaciones históricas, umbrales sostenidos).
Donde guardar las series: comparativa
| Base | Modelo | Retención | Agregación | Escritura | Encaja cuando |
|---|---|---|---|---|---|
| SQLite | Relacional, un archivo | Manual, con DELETE | SQL con GROUP BY | Un escritor a la vez | Prototipo, un dispositivo, laboratorio |
| Recorder de Home Assistant | SQLite o Postgres | purge_keep_days, por defecto 10 | Estadísticas de largo plazo automáticas | Gestionada por HA | La domótica misma |
| Prometheus | Series temporales, pull | --storage.tsdb.retention.time | PromQL en tiempo de consulta | Solo por scrape | Métricas numéricas, alertas, ecosistema estándar |
| InfluxDB | Series temporales, push | Políticas de retención por bucket | Flux o InfluxQL | HTTP, línea de protocolo | Escritura de alta frecuencia desde dispositivos |
| TimescaleDB | Postgres con hipertablas | Políticas de retención | SQL completo + agregados continuos | SQL estándar | Ya usas Postgres y quieres joins reales |
| VictoriaMetrics | Compatible con Prometheus | Configurable | PromQL/MetricsQL | Pull y push | Prometheus con menos consumo de recursos |
Para un despliegue casero de una decena de sensores, Prometheus alimentado desde Home Assistant es el punto dulce: cero código de ingesta, retención configurable, y Grafana lo entiende nativamente. Si los dispositivos publican a 10 Hz o más, o si necesitas escribir directamente desde el firmware sin pasar por Home Assistant, InfluxDB con su línea de protocolo es más adecuada, porque acepta escrituras push por HTTP.
Plataformas IoT integradas: ThingsBoard y OpenRemote
Hay una alternativa al stack de piezas sueltas: plataformas que traen todas las capas en un solo producto.
ThingsBoard es una plataforma IoT de código abierto que integra gestión de dispositivos, ingesta por MQTT/CoAP/HTTP/LwM2M, almacenamiento en Cassandra o Postgres, un motor de reglas visual y dashboards, incluyendo componentes SCADA para representar procesos industriales. Su modelo central son los assets y los devices, organizados jerárquicamente y con multi-tenancy real: puedes tener clientes con sus propios dispositivos y dashboards aislados.
OpenRemote apunta a un perfil parecido con un enfoque distinto: aprovisionamiento automático de dispositivos, un modelo de assets con atributos tipados, reglas de automatización expresadas como condiciones “cuando-entonces” en una interfaz visual, y soporte nativo para gemelos digitales de instalaciones.
# ThingsBoard Community Edition, evaluacion rapida
docker run -d --name thingsboard --restart unless-stopped \
-p 8080:9090 -p 1883:1883 -p 7070:7070 -p 5683-5688:5683-5688/udp \
-v ~/services/thingsboard/data:/data \
thingsboard/tb-postgres
| Criterio | Stack HA + Prometheus + Grafana | ThingsBoard | OpenRemote |
|---|---|---|---|
| Modelo de dominio | Entidades planas de domótica | Assets y devices jerárquicos | Assets con atributos tipados |
| Multi-tenancy | No | Sí, nativa | Sí |
| Gestión de dispositivos | Manual o por descubrimiento | Aprovisionamiento y credenciales por dispositivo | Aprovisionamiento automático |
| Motor de reglas | YAML de automatizaciones | Cadenas de reglas visuales | Reglas cuando-entonces |
| Visualización | Grafana, muy flexible | Dashboards integrados y SCADA | Dashboards integrados |
| Recursos que consume | Moderado, se puede repartir | Alto, necesita base de datos dedicada | Alto |
| Curva de aprendizaje | Media, tres herramientas distintas | Alta, modelo propio | Alta |
| Encaja en | Casa, laboratorio, prototipos | Producto comercial con muchos clientes | Instalaciones y ciudades |
El criterio para elegir es el número de dispositivos y de dueños. Diez sensores tuyos: el stack de piezas sueltas, porque cada pieza se puede cambiar. Quinientos dispositivos repartidos entre veinte clientes que necesitan ver solo lo suyo: una plataforma integrada, porque construir multi-tenancy a mano es un proyecto en sí mismo.
Errores comunes y como resolverlos
| Síntoma | Causa | Solución |
|---|---|---|
pio device list no muestra ningún puerto en Linux | Faltan reglas udev o el usuario no está en dialout | Instalar 99-platformio-udev.rules, usermod -aG dialout $USER, cerrar y abrir sesión |
| El upload falla con “Failed to connect to ESP32” | La placa no entra en modo bootloader | Mantener pulsado BOOT mientras arranca el upload, o añadir un capacitor de 10 uF entre EN y GND |
| El monitor serie muestra caracteres basura | monitor_speed no coincide con la velocidad del firmware | Igualar monitor_speed con el Serial.begin() del código |
| Un crash del ESP32 muestra solo direcciones hexadecimales | Falta el decodificador de excepciones | Añadir monitor_filters = esp32_exception_decoder |
lib_deps instala una versión distinta en cada máquina | Librería declarada sin restricción de versión | Fijar semver: autor/Libreria @ ^2.2.4 |
| Home Assistant no arranca tras editar el YAML | Error de indentación o clave inválida | Ejecutar check_config antes de reiniciar; revisar el log del contenedor |
El sensor REST queda en unknown | El value_template no coincide con la forma del JSON | Probar el endpoint con curl y ajustar la plantilla en Developer Tools → Template |
| El sensor desaparece del gráfico tras diez días | Falta state_class: measurement | Añadirlo para habilitar estadísticas de largo plazo |
| Un sensor MQTT no aparece pese a publicar bien | El mensaje de descubrimiento no está retenido o el prefijo no coincide | Publicar con retain: true bajo el prefijo homeassistant/ y verificar con mosquitto_sub -t 'homeassistant/#' |
| Una entidad MQTT vieja no se borra | El mensaje de configuración sigue retenido en el broker | Publicar un mensaje vacío en ese topic con -r -n |
| El ESP32 aparece “disponible” aunque esté apagado | No se configuró Last Will and Testament | Declarar will en la conexión MQTT y availability_topic en Home Assistant |
| Grafana muestra “No data” con SQLite | Ruta del archivo incorrecta dentro del contenedor | Montar el directorio como volumen y usar su ruta interna |
| El selector de tiempo del dashboard no afecta al panel SQLite | La query no usa las macros de tiempo | Añadir WHERE time BETWEEN $__unixEpochFrom() AND $__unixEpochTo() |
El objetivo hass aparece DOWN en Prometheus | Token inválido, prometheus: no habilitado o red de Docker equivocada | Probar el endpoint con curl y Bearer token; revisar network_mode |
| Prometheus almacena cientos de series inútiles | El bloque filter no está configurado | Restringir con include_domains y exclude_entities |
| El gráfico tiene picos absurdos | Lecturas fuera del rango físico del sensor | Filtrar en la query o aplicar avg_over_time para suavizar |
| Llegan avalanchas de alertas por un pico | Pending period en cero | Fijar un pending period acorde al fenómeno, típicamente entre 5 y 15 minutos |
| Un dispositivo caído no dispara ninguna alerta | Solo hay alertas de umbral | Añadir una alerta con absent() o basada en la métrica up |
| El disco se llena en semanas | Sin política de retención | Ajustar --storage.tsdb.retention.time y purge_keep_days del recorder |
| El ESP32 se reconecta al broker en bucle | Dos dispositivos con el mismo client_id | Asignar un client_id único, típicamente derivado de la MAC |
Ejercicios propuestos
-
Proyecto multi-entorno. Crea un proyecto de PlatformIO con dos entornos que compilen el mismo código para
esp32devy paraesp32-c3-devkitm-1. Usabuild_flagsheredados de[env]para el SSID y la IP del broker, y comprueba conpio runque ambos binarios se generan. -
Simulación antes que hardware. Añade
wokwi.tomlydiagram.jsona ese proyecto con un DHT22 y un LED. Verifica en el simulador que el firmware lee el sensor y hace parpadear el LED antes de tocar la placa física. -
Stack completo y broker autenticado. Levanta el
docker-compose.ymldel capítulo en una máquina limpia, documenta qué puerto ocupa cada servicio, y configura Mosquitto conallow_anonymous falsey dos usuarios. Verifica conmosquitto_subque sin credenciales la conexión se rechaza. -
REST contra MQTT, medido. Implementa las dos variantes del ESP32 del capítulo. Mide con un cronómetro cuánto tarda cada una en reflejar en Home Assistant un cambio brusco de temperatura, por ejemplo acercando la mano al sensor. Anota la diferencia.
-
Descubrimiento automático. Haz que el ESP32 publique su mensaje de descubrimiento retenido. Borra las entidades desde la interfaz de Home Assistant y reinicia el contenedor: deben reaparecer solas. Explica por qué.
-
Detección de caída. Configura Last Will and Testament en el ESP32 y
availability_topicen Home Assistant. Desconecta la alimentación de la placa y comprueba cuánto tarda la entidad en marcarse como no disponible. Relaciona ese tiempo con el keepalive de MQTT. -
Recolector robusto. Extiende el script de Elixir con SQLite para que registre en una segunda tabla cada fallo de lectura con su motivo y su timestamp. Grafica en el mismo dashboard la tasa de fallos junto a la temperatura.
-
Del pull al push. Reemplaza SQLite por InfluxDB y haz que el recolector escriba usando la línea de protocolo por HTTP. Compara la complejidad del código y de la query de Grafana.
-
Dashboard con variables. Construye un dashboard de Grafana sobre Prometheus con una variable
$habitacionalimentada porlabel_values(hass_sensor_temperature_celsius, entity). Añade una fila destatcon los valores actuales y untime seriescon la media móvil de 15 minutos. -
Comparación con ayer. Crea un panel que superponga la temperatura actual con la de hace 24 horas usando el modificador
offset 24h. Interpreta qué te dice la diferencia. -
Dos alertas complementarias. Configura una alerta de umbral con pending period de 10 minutos y otra de ausencia de datos con
absent(). Provoca ambas condiciones a propósito y verifica que solo la segunda detecta el dispositivo apagado. -
Infraestructura como código. Aprovisiona la fuente de datos y el dashboard mediante archivos en
provisioning/. Borra el volumen de Grafana, levanta el contenedor de nuevo y comprueba que todo reaparece sin tocar la interfaz. -
Evaluación de plataforma integrada. Levanta ThingsBoard con Docker, registra un dispositivo, publica lecturas por MQTT con sus credenciales y construye un dashboard equivalente al de Grafana. Compara tiempo invertido, flexibilidad y consumo de recursos.
Cierre
Con este capítulo el ESP32 dejó de ser una placa que imprime números en un terminal. Ahora hay un flujo de compilación reproducible sobre PlatformIO, un transporte que sobrevive a reinicios y a IPs cambiantes sobre MQTT, un cerebro local que convierte lecturas en entidades y entidades en acciones sobre Home Assistant, una base de series temporales que retiene el histórico, y paneles y alertas que traducen esos datos en decisiones. Eso es un sistema de telemetría completo, y es la infraestructura sobre la que se apoya cualquier proyecto de robótica o IoT que dure más de una tarde.
Lo que falta es el mundo físico y el software que lo controla en tiempo real. En el capitulo 12 bajamos a diseñar las piezas que sostienen la electrónica con herramientas CAD, revisamos dónde comprar componentes sin quedar atrapado en plazos imposibles, y entramos en los sistemas operativos que gobiernan un robot: los RTOS y sus garantías de latencia acotada, y ROS con su modelo de nodos, tópicos y servicios, que es el equivalente robótico de todo lo que acabamos de montar para telemetría. El índice completo del curso vive en /tecnologias/elixir-robotics/00-indice/.