Herramientas del ecosistema: PlatformIO, Home Assistant, Grafana y dashboards de telemetria

Por: Artiko
elixirroboticaiotelectronicaplatformiohome-assistantgrafanamqtttelemetriaatomvm

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:

CapaPregunta que resuelveSi 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:

  • platform es la familia de silicio y su toolchain (espressif32, espressif8266, ststm32, atmelavr, raspberrypi, nordicnrf52).
  • board es el modelo concreto de placa. Define frecuencia de CPU, tamaño de flash, tamaño de RAM y esquema de particiones por defecto.
  • framework es la API con la que programas encima. Para ESP32 puedes elegir arduino o espidf, y en algunos casos ambos a la vez con framework = arduino, espidf.
  • lib_deps acepta 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

CriterioPlatformIOArduino IDE 2.xESP-IDF (idf.py)Zephyr (west)
Placas soportadasMás de 1500, multi-fabricanteAmplio vía gestores de placasSolo EspressifMuy amplio, orientado a RTOS
ConfiguraciónArchivo platformio.ini versionableGUI, estado global del IDEsdkconfig + Kconfigprj.conf + devicetree
ReproducibilidadAlta: dependencias fijadas por semverBaja: depende de lo instaladoAlta si se fija la versión del IDFAlta
Curva de aprendizajeMediaBajaAltaMuy alta
Acceso a APIs de bajo nivelTotal si usas framework = espidfLimitadoTotalTotal
Integración con CINativa, todo por CLIIncómodaBuenaBuena
Depuración con breakpointsSí, vía debug_tool y OpenOCDLimitada
Simulación integradaWokwi vía extensiónNoQEMU parcialSí, 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étodoQué incluyeAdd-onsActualizaciónCuándo usarlo
Home Assistant OSSistema operativo completo + supervisor + coreDesde la interfazRaspberry Pi o mini PC dedicado
Home Assistant ContainerSolo el core, en DockerNodocker pull manualServidor que ya corre otros contenedores
Home Assistant SupervisedCore + supervisor sobre Debian propioDesde la interfazCasos avanzados con hardware específico
Home Assistant CorePaquete Python en un venvNopip install --upgradeDesarrollo 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, recorder purga 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

AspectoREST (pull)MQTT (push)
Quién iniciaHome Assistant consultaEl dispositivo publica
Latencia de un eventoHasta un scan_interval completoMilisegundos
Dispositivo con IP dinámicaSe rompeFunciona, el dispositivo busca al broker
Dispositivo que duermeIncompatibleCompatible, publica al despertar
Detección de caídaTimeout de la peticiónLast Will and Testament inmediato
Consumo del dispositivoDebe mantener servidor escuchandoPuede dormir entre publicaciones
Sobrecarga por mensajeCabeceras HTTP completas2 bytes de cabecera fija mínima
Infraestructura extraNingunaUn broker
Depuracióncurlmosquitto_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:

  1. 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.
  2. 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.
  3. 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.

ConsultaQué devuelve
hass_sensor_temperature_celsiusTodas 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 24hDiferencia 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 panelPregunta que respondeCuá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:

  1. Una fila superior de stat con lo esencial. Quien abre el dashboard debe saber en tres segundos si algo va mal.
  2. Unidades siempre. En Panel options → Standard options → Unit elige la unidad real (celsius, percent, volt, watt). Grafana formatea y escala solo.
  3. 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.
  4. Ejes fijos cuando el rango físico es conocido. Si el sensor mide entre 0 y 100 %, fija min y max. Un eje automático hace que el ruido de medio punto parezca una crisis.
  5. Variables para no duplicar dashboards. En Dashboard settings → Variables, crea una variable de tipo Query llamada habitacion con la consulta label_values(hass_sensor_temperature_celsius, entity), y úsala en los paneles como hass_sensor_temperature_celsius{entity="$habitacion"}. Un solo dashboard sirve para todas las habitaciones, con un desplegable arriba.
  6. 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.
  7. Rango de tiempo por defecto sensato. Si tu scrape_interval es de 60 segundos, un rango por defecto de 5 minutos muestra cinco puntos. Last 6 hours o Last 24 hours suele 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.

  1. Query A: avg_over_time(hass_sensor_temperature_celsius{entity="sensor.temperatura_sala"}[10m]).
  2. Expression B, tipo Reduce, función Last, entrada A.
  3. Expression C, tipo Threshold, entrada B, condición IS ABOVE 30.
  4. Evaluation: grupo evaluado cada 1m, con pending period de 5m.
  5. 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

BaseModeloRetenciónAgregaciónEscrituraEncaja cuando
SQLiteRelacional, un archivoManual, con DELETESQL con GROUP BYUn escritor a la vezPrototipo, un dispositivo, laboratorio
Recorder de Home AssistantSQLite o Postgrespurge_keep_days, por defecto 10Estadísticas de largo plazo automáticasGestionada por HALa domótica misma
PrometheusSeries temporales, pull--storage.tsdb.retention.timePromQL en tiempo de consultaSolo por scrapeMétricas numéricas, alertas, ecosistema estándar
InfluxDBSeries temporales, pushPolíticas de retención por bucketFlux o InfluxQLHTTP, línea de protocoloEscritura de alta frecuencia desde dispositivos
TimescaleDBPostgres con hipertablasPolíticas de retenciónSQL completo + agregados continuosSQL estándarYa usas Postgres y quieres joins reales
VictoriaMetricsCompatible con PrometheusConfigurablePromQL/MetricsQLPull y pushPrometheus 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
CriterioStack HA + Prometheus + GrafanaThingsBoardOpenRemote
Modelo de dominioEntidades planas de domóticaAssets y devices jerárquicosAssets con atributos tipados
Multi-tenancyNoSí, nativa
Gestión de dispositivosManual o por descubrimientoAprovisionamiento y credenciales por dispositivoAprovisionamiento automático
Motor de reglasYAML de automatizacionesCadenas de reglas visualesReglas cuando-entonces
VisualizaciónGrafana, muy flexibleDashboards integrados y SCADADashboards integrados
Recursos que consumeModerado, se puede repartirAlto, necesita base de datos dedicadaAlto
Curva de aprendizajeMedia, tres herramientas distintasAlta, modelo propioAlta
Encaja enCasa, laboratorio, prototiposProducto comercial con muchos clientesInstalaciones 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íntomaCausaSolución
pio device list no muestra ningún puerto en LinuxFaltan reglas udev o el usuario no está en dialoutInstalar 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 bootloaderMantener pulsado BOOT mientras arranca el upload, o añadir un capacitor de 10 uF entre EN y GND
El monitor serie muestra caracteres basuramonitor_speed no coincide con la velocidad del firmwareIgualar monitor_speed con el Serial.begin() del código
Un crash del ESP32 muestra solo direcciones hexadecimalesFalta el decodificador de excepcionesAñadir monitor_filters = esp32_exception_decoder
lib_deps instala una versión distinta en cada máquinaLibrería declarada sin restricción de versiónFijar semver: autor/Libreria @ ^2.2.4
Home Assistant no arranca tras editar el YAMLError de indentación o clave inválidaEjecutar check_config antes de reiniciar; revisar el log del contenedor
El sensor REST queda en unknownEl value_template no coincide con la forma del JSONProbar el endpoint con curl y ajustar la plantilla en Developer Tools → Template
El sensor desaparece del gráfico tras diez díasFalta state_class: measurementAñadirlo para habilitar estadísticas de largo plazo
Un sensor MQTT no aparece pese a publicar bienEl mensaje de descubrimiento no está retenido o el prefijo no coincidePublicar con retain: true bajo el prefijo homeassistant/ y verificar con mosquitto_sub -t 'homeassistant/#'
Una entidad MQTT vieja no se borraEl mensaje de configuración sigue retenido en el brokerPublicar un mensaje vacío en ese topic con -r -n
El ESP32 aparece “disponible” aunque esté apagadoNo se configuró Last Will and TestamentDeclarar will en la conexión MQTT y availability_topic en Home Assistant
Grafana muestra “No data” con SQLiteRuta del archivo incorrecta dentro del contenedorMontar el directorio como volumen y usar su ruta interna
El selector de tiempo del dashboard no afecta al panel SQLiteLa query no usa las macros de tiempoAñadir WHERE time BETWEEN $__unixEpochFrom() AND $__unixEpochTo()
El objetivo hass aparece DOWN en PrometheusToken inválido, prometheus: no habilitado o red de Docker equivocadaProbar el endpoint con curl y Bearer token; revisar network_mode
Prometheus almacena cientos de series inútilesEl bloque filter no está configuradoRestringir con include_domains y exclude_entities
El gráfico tiene picos absurdosLecturas fuera del rango físico del sensorFiltrar en la query o aplicar avg_over_time para suavizar
Llegan avalanchas de alertas por un picoPending period en ceroFijar un pending period acorde al fenómeno, típicamente entre 5 y 15 minutos
Un dispositivo caído no dispara ninguna alertaSolo hay alertas de umbralAñadir una alerta con absent() o basada en la métrica up
El disco se llena en semanasSin política de retenciónAjustar --storage.tsdb.retention.time y purge_keep_days del recorder
El ESP32 se reconecta al broker en bucleDos dispositivos con el mismo client_idAsignar un client_id único, típicamente derivado de la MAC

Ejercicios propuestos

  1. Proyecto multi-entorno. Crea un proyecto de PlatformIO con dos entornos que compilen el mismo código para esp32dev y para esp32-c3-devkitm-1. Usa build_flags heredados de [env] para el SSID y la IP del broker, y comprueba con pio run que ambos binarios se generan.

  2. Simulación antes que hardware. Añade wokwi.toml y diagram.json a 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.

  3. Stack completo y broker autenticado. Levanta el docker-compose.yml del capítulo en una máquina limpia, documenta qué puerto ocupa cada servicio, y configura Mosquitto con allow_anonymous false y dos usuarios. Verifica con mosquitto_sub que sin credenciales la conexión se rechaza.

  4. 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.

  5. 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é.

  6. Detección de caída. Configura Last Will and Testament en el ESP32 y availability_topic en 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.

  7. 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.

  8. 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.

  9. Dashboard con variables. Construye un dashboard de Grafana sobre Prometheus con una variable $habitacion alimentada por label_values(hass_sensor_temperature_celsius, entity). Añade una fila de stat con los valores actuales y un time series con la media móvil de 15 minutos.

  10. 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.

  11. 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.

  12. 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.

  13. 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/.