IoT de punta a punta y el bus I2C sin misterios
IoT de punta a punta y el bus I2C sin misterios
En el capítulo 9 cerramos la escalera del cómputo: desde el microcontrolador de ocho pesos hasta el ASIC, pasando por SBCs, módulos Jetson, SoCs con FPGA como los Kria y la matriz de decisión para elegir entre todos ellos. Terminamos con un dispositivo capaz. Lo que no tenemos todavía es un dispositivo conectado ni un dispositivo que sienta.
Este capítulo cubre exactamente esas dos brechas, y las cubre en el orden en que aparecen cuando uno construye algo de verdad. Primero, hacia arriba: qué significa realmente que un aparato sea “IoT”, cómo se estructura un sistema de telemetría desde el sensor hasta el gráfico, qué protocolo de radio elegir, cómo funciona MQTT paquete por paquete, qué se rompe cuando la seguridad se deja para el final y qué implica administrar mil dispositivos en terreno que no puedes ir a visitar. Segundo, hacia abajo: cómo hablan los sensores con el procesador a través de I2C, un bus de dos cables que parece trivial y que concentra la mayoría de las horas perdidas de cualquier proyecto de hardware.
Al terminar vas a poder cablear un sensor, escanear el bus, escribir un driver completo en Elixir, empaquetar la lectura y publicarla a un broker MQTT con TLS mutuo, y explicar por qué el bus dejó de funcionar cuando conectaste el cuarto módulo.
Qué es IoT y qué no lo es
Internet de las Cosas es la interconexión de objetos físicos cotidianos a través de redes de datos, de modo que puedan recolectar información del mundo, intercambiarla y actuar sobre él. La definición es tan amplia que sirve para poco. Lo útil es identificar los cuatro rasgos que distinguen un sistema IoT de una aplicación web común:
- El dato nace en el mundo físico. No lo escribe un humano en un formulario: lo produce un sensor, con ruido, deriva, unidades e incertidumbre. Un valor de temperatura de 23,4 °C tiene detrás un ADC, una referencia de voltaje y una curva de calibración.
- El dispositivo es hostil de operar. Está en un techo, en un contenedor refrigerado, en un poste rural o dentro de una máquina. No tiene teclado, no tiene pantalla y visitarlo cuesta dinero real.
- La red es intermitente y cara, con enlaces que se caen, cobertura celular parcial, baterías que hay que estirar años y planes de datos por megabyte. Y la flota crece: un dispositivo es un experimento; mil dispositivos son un problema de logística, de identidad, de versiones y de seguridad.
Cuando el mismo enfoque se aplica a procesos productivos —líneas de manufactura, minería, energía, logística— se habla de IIoT o Internet Industrial de las Cosas. Cambia el vocabulario y sube la exigencia: se suman buses de campo históricos como Modbus o PROFIBUS, requisitos de disponibilidad continua, ambientes eléctricamente ruidosos y normativa de seguridad funcional. La arquitectura de fondo, sin embargo, es la misma.
Las cinco capas de un sistema IoT
Casi cualquier despliegue real se descompone en las mismas cinco capas. Ubicar mentalmente cada pieza en su capa evita el error más común de los proyectos nuevos, que es mezclar responsabilidades y terminar con un dispositivo que intenta ser también base de datos y servidor web.
flowchart TB
subgraph L1["1. Percepcion y actuacion"]
S1[Sensor I2C: BME280]
S2[Sensor SPI: acelerometro]
A1[Actuador: rele o motor]
end
subgraph L2["2. Nodo o dispositivo"]
MCU[Microcontrolador o SBC<br/>firmware, drivers, buffer local]
end
subgraph L3["3. Conectividad"]
R1[WiFi / Ethernet]
R2[BLE / Zigbee / Thread]
R3[LoRaWAN / NB-IoT]
end
subgraph L4["4. Plataforma"]
GW[Gateway o concentrador]
BR[Broker MQTT]
API[Servicio de ingesta]
DB[(Base de datos de series de tiempo)]
end
subgraph L5["5. Aplicacion"]
DASH[Dashboard]
ALERT[Motor de alertas]
ML[Analitica y modelos]
end
S1 --> MCU
S2 --> MCU
MCU --> A1
MCU --> R1
MCU --> R2
MCU --> R3
R1 --> BR
R2 --> GW
R3 --> GW
GW --> BR
BR --> API
API --> DB
DB --> DASH
DB --> ML
BR --> ALERT
ALERT -.comando de vuelta.-> BR
BR -.suscripcion de control.-> MCU
Observa la flecha punteada de vuelta. Un sistema IoT que solo sube datos es un sistema de telemetría; recién cuando el mismo canal permite bajar comandos —encender el riego, cambiar el umbral de alarma, pedir un reinicio— se vuelve un sistema de control distribuido, y ahí aparecen todos los problemas interesantes de seguridad y consistencia.
Edge, fog y nube: dónde se procesa qué
Una decisión temprana que condiciona todo lo demás es cuánto procesamiento ocurre en el dispositivo. Las tres posiciones clásicas:
| Enfoque | Dónde corre la lógica | Latencia típica | Costo de red | Cuándo conviene |
|---|---|---|---|---|
| Nube pura | Todo en el servidor; el nodo solo transmite lecturas crudas | 100 ms a varios segundos | Alto: cada muestra viaja | Pocos dispositivos, enlace barato, lógica que cambia seguido |
| Edge | El nodo filtra, agrega, decide y solo reporta eventos | Microsegundos a milisegundos | Bajo: se transmiten resúmenes | Lazos de control, enlaces caros o intermitentes, privacidad |
| Fog / gateway | Un equipo intermedio agrupa a varios nodos y preprocesa | 1 a 50 ms | Medio | Radios de corto alcance, protocolos legados, sitios con muchos nodos |
La regla práctica: todo lazo de control que pueda dañar algo si la red se cae debe cerrarse en el edge. La nube decide políticas y umbrales; el dispositivo decide acciones. Un invernadero que apaga el calefactor solo cuando el servidor se lo ordena quema las plantas el día que se corte la fibra.
El espectro de conectividad
No existe “la red del IoT”. Existe un espectro donde se negocian tres variables que no se pueden maximizar a la vez: alcance, ancho de banda y consumo. Esta tabla resume las opciones que vas a encontrar en la práctica.
| Tecnología | Alcance típico | Tasa de datos | Consumo | Topología | Uso característico |
|---|---|---|---|---|---|
| BLE (Bluetooth Low Energy) | 10 a 50 m | 125 kbps a 2 Mbps | Muy bajo; años con pila de botón | Estrella, o malla con Bluetooth Mesh | Wearables, sensores de proximidad, configuración desde el teléfono |
| WiFi 2,4/5 GHz | 30 a 100 m | 10 a 500 Mbps | Alto; necesita alimentación o batería grande | Estrella vía punto de acceso | Cámaras, dispositivos con red eléctrica, prototipos con ESP32 |
| Ethernet | 100 m por tramo | 100 Mbps a 1 Gbps | Alto, pero admite PoE | Cableada | Industria, gateways, equipos fijos críticos |
| Zigbee | 10 a 100 m por salto | 250 kbps | Bajo | Malla | Domótica, iluminación, sensores de puerta |
| Thread / Matter | 10 a 100 m por salto | 250 kbps | Bajo | Malla con IPv6 nativo | Domótica moderna interoperable |
| LoRaWAN | 2 a 15 km según terreno | 0,3 a 50 kbps | Mínimo; hasta 10 años con pilas | Estrella de estrellas vía gateways | Agricultura, medidores, sensores urbanos dispersos |
| NB-IoT | Cobertura celular | 20 a 250 kbps | Bajo | Celular | Medidores en subterráneos, activos móviles |
| LTE-M | Cobertura celular | 300 kbps a 1 Mbps | Medio | Celular, con handover | Rastreo de vehículos, telemetría móvil |
| Sigfox / LPWAN propietarias | 3 a 40 km | 100 bps, mensajes muy cortos | Mínimo | Estrella | Alarmas simples, seguimiento de bajo volumen |
Tres advertencias que ahorran meses:
- LoRa no es LoRaWAN. LoRa es la modulación de capa física; LoRaWAN es el protocolo de red encima, con su servidor, sus claves y su plan regional de frecuencias. En Chile el plan regional habitual es AU915, y un módulo comprado con firmware para EU868 no va a funcionar aunque el hardware sea el mismo.
- LoRaWAN tiene clases. En clase A el dispositivo solo escucha durante dos ventanas cortas después de transmitir, lo que hace el consumo mínimo pero vuelve la bajada de comandos lenta e incierta. Clase C escucha casi siempre y consume mucho más. Elegir clase A y después pedir control en tiempo real es una contradicción de diseño.
- El ciclo de trabajo está regulado: las bandas sin licencia limitan cuánto tiempo puedes transmitir, así que un sensor LoRaWAN no puede reportar cada segundo por mucho que el código lo permita.
MQTT en detalle
MQTT es el protocolo de aplicación dominante en IoT y merece una sección larga, porque casi todos los problemas de un sistema de telemetría maduro se resuelven con opciones de MQTT que la mayoría nunca usa.
Es un protocolo publicador/suscriptor sobre TCP, diseñado en 1999 para oleoductos con enlaces satelitales caros. Sus cabeceras son mínimas —el paquete más chico ocupa dos bytes— y su modelo desacopla completamente a quien produce el dato de quien lo consume: nadie conoce la dirección IP del otro, solo el nombre de un topic.
Las piezas
- Broker: el servidor central. Recibe publicaciones y las reparte a los suscriptores. Implementaciones habituales: Mosquitto (liviano, un binario), EMQX (clúster, millones de conexiones), VerneMQ (escrito en Erlang), HiveMQ, NanoMQ para el edge. El cliente es cualquier cosa que se conecte: un sensor publica y un dashboard se suscribe, pero ambos son clientes del mismo tipo.
- Topic: una cadena jerárquica separada por
/, por ejemploplanta/linea-3/prensa-07/temperatura. No se declara ni se crea: existe en cuanto alguien publica en él. - Client ID: identificador único por conexión. Si dos clientes se conectan con el mismo ID, el broker desconecta al primero. Es una causa clásica de dispositivos que “se reinician solos” en bucle.
Puertos por convención: 1883 para TCP plano, 8883 para TLS. Los puertos de WebSocket dependen del broker.
Topics, jerarquía y comodines
Un topic bien diseñado se lee como una ruta de sistema de archivos y va de lo general a lo específico. Al suscribirse se pueden usar dos comodines:
| Comodín | Significado | Ejemplo | Coincide con |
|---|---|---|---|
+ | Exactamente un nivel | planta/+/prensa-07/temperatura | planta/linea-3/prensa-07/temperatura |
# | Todos los niveles restantes; solo al final | planta/linea-3/# | Todo lo que cuelgue de la línea 3 |
Los comodines solo son válidos al suscribirse. Publicar en sensores/# es un error de protocolo. Y una regla de higiene: los topics que empiezan con $ están reservados para el broker; $SYS/# expone sus métricas internas.
Un esquema de nombres que funciona a escala:
<organizacion>/<sitio>/<dispositivo>/<canal>/<subcanal>
acme/santiago-01/dev-8f21c4/telemetria/temperatura
acme/santiago-01/dev-8f21c4/telemetria/humedad
acme/santiago-01/dev-8f21c4/estado/salud
acme/santiago-01/dev-8f21c4/comando/rele-1
acme/santiago-01/dev-8f21c4/comando/rele-1/respuesta
Poner el identificador del dispositivo temprano en la jerarquía permite escribir reglas de ACL del tipo “este certificado solo puede publicar bajo su propio prefijo”, que es la base de la seguridad de la flota.
Calidad de servicio
MQTT ofrece tres niveles de garantía de entrega. Elegir mal es caro en ambas direcciones: de menos, se pierden datos; de más, se gasta batería y ancho de banda en confirmaciones.
| QoS | Nombre | Paquetes intercambiados | Garantía | Costo |
|---|---|---|---|---|
| 0 | At most once | PUBLISH | Puede perderse; nunca se duplica | 1 mensaje |
| 1 | At least once | PUBLISH → PUBACK | Llega seguro; puede duplicarse | 2 mensajes |
| 2 | Exactly once | PUBLISH → PUBREC → PUBREL → PUBCOMP | Llega exactamente una vez | 4 mensajes |
sequenceDiagram
participant D as Dispositivo
participant B as Broker
participant S as Suscriptor
Note over D,B: Conexion inicial
D->>B: CONNECT client_id, keep_alive 60, will
B-->>D: CONNACK codigo 0, session_present false
D->>B: SUBSCRIBE acme/sitio/dev-01/comando/#, qos 1
B-->>D: SUBACK qos concedido 1
Note over D,B: Telemetria con QoS 1
D->>B: PUBLISH temperatura, packet_id 42, qos 1
B->>S: PUBLISH temperatura
B-->>D: PUBACK packet_id 42
Note over D,B: Comando critico con QoS 2
S->>B: PUBLISH abrir valvula, packet_id 7, qos 2
B-->>S: PUBREC packet_id 7
S->>B: PUBREL packet_id 7
B-->>S: PUBCOMP packet_id 7
B->>D: PUBLISH abrir valvula, qos 2
Note over D,B: Mantenimiento del enlace
D->>B: PINGREQ
B-->>D: PINGRESP
Note over D,B: Caida sin DISCONNECT
D--xB: enlace cortado
B->>S: PUBLISH estado offline, retained
Regla práctica: QoS 0 para telemetría periódica, porque si se pierde una muestra la siguiente llega en diez segundos; QoS 1 para eventos y comandos, aceptando que el receptor debe ser idempotente; QoS 2 casi nunca, porque el costo de cuatro viajes rara vez compensa frente a diseñar comandos idempotentes con identificador propio.
Retained, Last Will y sesiones
Tres mecanismos que resuelven problemas concretos y que suelen ignorarse:
- Mensaje retenido (
retain: true): el broker guarda el último mensaje de ese topic y se lo entrega de inmediato a cualquiera que se suscriba después. Sirve para estado, no para eventos. El topic.../estado/saluddebe ser retenido;.../evento/boton-presionadojamás, porque un suscriptor nuevo recibiría un botón presionado hace tres días. Para borrar un mensaje retenido se publica una carga útil vacía conretain: true. - Last Will and Testament: al conectarse, el cliente le entrega al broker un mensaje que este publicará si el cliente desaparece sin decir adiós. Combinado con retained, da presencia real: el dispositivo publica
{"estado":"online"}retenido al conectar y registra como testamento{"estado":"offline"}retenido. Sin esto, distinguir “sensor apagado” de “sensor sin novedades” es imposible. - Sesión persistente: si el cliente se conecta con
clean_session: false(MQTT 3.1.1) oclean_start: falsemás session expiry (MQTT 5), el broker conserva sus suscripciones y los mensajes QoS 1 y 2 pendientes mientras estuvo desconectado. Es lo que quieres en un dispositivo que despierta cada media hora, y lo que no quieres en un dashboard, donde acumularía miles de mensajes viejos. El keep alive, en cambio, es el intervalo en segundos que el cliente promete no superar sin enviar nada: si pasa 1,5 veces ese tiempo sin tráfico, el broker declara muerta la conexión y dispara el testamento. Valores de 60 a 300 segundos son razonables; muy bajo gasta batería en PINGREQ, muy alto retrasa la detección de caídas.
Qué agrega MQTT 5
La versión 5 mantiene el modelo pero suma piezas que importan a escala:
- Códigos de razón en las respuestas: ya no es “falló”, es “no autorizado”, “topic inválido”, “cuota excedida”. Y propiedades de usuario, pares clave-valor arbitrarios por mensaje para trazabilidad sin ensuciar la carga útil.
- Response topic y correlation data: patrón petición/respuesta estandarizado, ideal para comandos que necesitan confirmación.
- Suscripciones compartidas (
$share/grupo/topic): varios consumidores se reparten los mensajes de un topic en lugar de recibirlos todos. Es la manera correcta de escalar el backend de ingesta horizontalmente. - Expiración de mensajes, que descarta solo un comando que ya no tiene sentido, y topic alias, que reemplaza el topic largo por un número de dos bytes tras el primer envío.
Comparación con las alternativas
| Protocolo | Transporte | Modelo | Cabecera mínima | Fuerte en | Débil en |
|---|---|---|---|---|---|
| MQTT | TCP | Pub/sub con broker | 2 bytes | Enlaces intermitentes, muchos consumidores, comandos | Requiere broker siempre disponible |
| CoAP | UDP | REST petición/respuesta | 4 bytes | Nodos muy limitados, multicast, sin broker | Entrega no garantizada sin confirmable |
| HTTP/REST | TCP | Petición/respuesta | Cientos de bytes | Integración trivial, herramientas abundantes | Costoso en batería y en datos |
| AMQP | TCP | Colas con enrutamiento rico | Decenas de bytes | Garantías transaccionales, backend empresarial | Pesado para un microcontrolador |
| Modbus TCP/RTU | TCP o serial | Maestro/esclavo por registros | 8 bytes | Compatibilidad industrial universal | Sin seguridad, sin descubrimiento |
| OPC UA | TCP | Modelo de información completo | Grande | Semántica industrial, seguridad integrada | Complejo, exige hardware capaz |
Un cliente MQTT completo en Elixir
En el ecosistema Elixir la biblioteca más usada para MQTT 3.1.1 es Tortoise311, un cliente supervisado que encaja naturalmente en un árbol OTP. Se agrega así:
# mix.exs
defp deps do
[
{:tortoise311, "~> 0.12"},
{:jason, "~> 1.4"}
]
end
El corazón es un módulo que implementa el comportamiento Tortoise311.Handler. Cada callback corresponde a un momento del ciclo de vida de la conexión:
defmodule Planta.MqttHandler do
@moduledoc """
Maneja el ciclo de vida de la conexion MQTT y los mensajes entrantes
del topic de comandos.
"""
use Tortoise311.Handler
require Logger
defstruct [:device_id, comandos_recibidos: 0]
@impl true
def init(device_id: device_id) do
{:ok, %__MODULE__{device_id: device_id}}
end
@impl true
def connection(:up, state) do
Logger.info("MQTT conectado")
# Publicamos presencia retenida apenas sube el enlace.
Tortoise311.publish(
state.device_id,
"acme/santiago-01/#{state.device_id}/estado/salud",
Jason.encode!(%{estado: "online", ts: System.system_time(:second)}),
qos: 1,
retain: true
)
{:ok, state}
end
def connection(:down, state) do
Logger.warning("MQTT desconectado; el broker publicara el testamento")
{:ok, state}
end
def connection(:terminating, state) do
{:ok, state}
end
@impl true
def subscription(:up, topic_filter, state) do
Logger.info("Suscripcion activa: #{topic_filter}")
{:ok, state}
end
def subscription({:warn, [requested: req, accepted: acc]}, filter, state) do
Logger.warning("QoS degradado en #{filter}: pedi #{req}, me dieron #{acc}")
{:ok, state}
end
def subscription(otro, filter, state) do
Logger.error("Suscripcion en #{filter} con estado #{inspect(otro)}")
{:ok, state}
end
@impl true
def handle_message(["acme", _sitio, _dev, "comando", actuador], payload, state) do
case Jason.decode(payload) do
{:ok, %{"accion" => accion, "id" => id}} ->
resultado = Planta.Actuadores.aplicar(actuador, accion)
responder(state.device_id, actuador, id, resultado)
otro ->
Logger.warning("Comando invalido o incompleto: #{inspect(otro)}")
end
{:ok, %{state | comandos_recibidos: state.comandos_recibidos + 1}}
end
def handle_message(topic, _payload, state) do
Logger.debug("Mensaje ignorado en #{Enum.join(topic, "/")}")
{:ok, state}
end
@impl true
def terminate(_reason, _state), do: :ok
defp responder(device_id, actuador, id, resultado) do
Tortoise311.publish(
device_id,
"acme/santiago-01/#{device_id}/comando/#{actuador}/respuesta",
Jason.encode!(%{id: id, resultado: inspect(resultado)}),
qos: 1
)
end
end
Fíjate en handle_message/3: el topic llega ya partido en una lista de niveles, lo que permite hacer pattern matching directo sobre la jerarquía. Es una de esas coincidencias felices entre el diseño de un protocolo y el diseño de un lenguaje.
La conexión se arranca dentro del árbol de supervisión de la aplicación:
defmodule Planta.Application do
use Application
@impl true
def start(_type, _args) do
device_id = Planta.Identidad.device_id()
children = [
Planta.Actuadores,
{Tortoise311.Connection,
client_id: device_id,
server: {
Tortoise311.Transport.SSL,
host: "mqtt.acme.cl",
port: 8883,
cacertfile: "/etc/ssl/acme-ca.pem",
certfile: "/data/certs/device.pem",
keyfile: "/data/certs/device-key.pem",
verify: :verify_peer,
server_name_indication: ~c"mqtt.acme.cl",
depth: 3
},
handler: {Planta.MqttHandler, [device_id: device_id]},
subscriptions: [{"acme/santiago-01/#{device_id}/comando/#", 1}],
keep_alive: 60,
will: %Tortoise311.Package.Publish{
topic: "acme/santiago-01/#{device_id}/estado/salud",
payload: Jason.encode!(%{estado: "offline"}),
qos: 1,
retain: true
}},
Planta.Telemetria
]
Supervisor.start_link(children, strategy: :one_for_one, name: Planta.Supervisor)
end
end
Tres detalles que no son decorativos:
verify: :verify_peeres obligatorio: sin esa opción el cliente acepta cualquier certificado y el TLS se vuelve teatro.certfileykeyfileimplementan TLS mutuo —el dispositivo también se identifica con un certificado propio—, que es lo que permite al broker aplicar ACL por dispositivo. Y elwillse registra en el CONNECT, antes de que exista ningún problema: no hay forma de agregarlo después.
Y el productor de telemetría, un GenServer con temporizador propio:
defmodule Planta.Telemetria do
use GenServer
require Logger
@intervalo_ms 10_000
def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
@impl true
def init(:ok) do
Process.send_after(self(), :medir, @intervalo_ms)
{:ok, %{device_id: Planta.Identidad.device_id(), fallos: 0}}
end
@impl true
def handle_info(:medir, state) do
state =
case Planta.Sensor.leer() do
{:ok, medicion} ->
publicar(state.device_id, medicion)
%{state | fallos: 0}
{:error, motivo} ->
Logger.warning("Lectura fallida: #{inspect(motivo)}")
%{state | fallos: state.fallos + 1}
end
Process.send_after(self(), :medir, @intervalo_ms)
{:noreply, state}
end
defp publicar(device_id, medicion) do
payload = Jason.encode!(Map.put(medicion, :ts, System.system_time(:millisecond)))
topic = "acme/santiago-01/#{device_id}/telemetria/paquete"
# QoS 0: si se pierde una muestra, la siguiente llega en 10 segundos.
Tortoise311.publish(device_id, topic, payload, qos: 0)
end
end
Seguridad de un dispositivo conectado
Un dispositivo IoT es un servidor sin administrador, expuesto a Internet, con firmware que nadie va a parchear a mano y credenciales que muchas veces vienen iguales de fábrica. Esa combinación produjo la botnet Mirai, que en 2016 tomó control de cientos de miles de cámaras y grabadores probando una lista corta de usuarios y contraseñas por defecto, y la usó para tumbar buena parte de Internet durante horas. El vector de ataque no fue sofisticado: fue admin/admin por Telnet.
Modelo de amenazas y contramedidas
| Amenaza | Cómo se materializa | Contramedida concreta |
|---|---|---|
| Credenciales por defecto | Usuario y clave iguales en toda la producción | Credencial única por dispositivo, generada en fábrica o en el primer arranque |
| Escucha del tráfico | MQTT en el puerto 1883 sin cifrar en una red compartida | TLS 1.2 o superior siempre; el 1883 solo dentro de un túnel |
| Suplantación del servidor | El dispositivo confía en cualquier certificado | Fijar la CA propia y usar verify_peer; comparar el nombre del servidor |
| Suplantación del dispositivo | Alguien copia la credencial y publica datos falsos | Certificado X.509 por dispositivo con clave privada no exportable |
| Dispositivo comprometido que ataca al resto | Publica en topics de otros o inunda el broker | ACL por certificado limitada a su propio prefijo; cuotas de mensajes |
| Extracción de secretos de la flash | Se desuelda el chip y se lee la memoria | Elemento seguro dedicado; cifrado de flash; deshabilitar interfaces de depuración |
| Firmware modificado | Se instala una imagen alterada | Arranque verificado y firmas criptográficas sobre cada actualización |
| Acceso físico por puertos abiertos | UART de consola y JTAG accesibles en la placa | Cerrar fusibles de depuración en producción; consola sin shell |
| Rollback a firmware vulnerable | Se reinstala una versión antigua con un fallo conocido | Contador antirreversión monótono en el gestor de arranque |
| Denegación por agotamiento | Miles de dispositivos reconectando en tropel tras un corte | Reconexión con retardo exponencial y desfase aleatorio |
Identidad y aprovisionamiento
La pregunta fundacional de la seguridad de una flota es: ¿cómo sabe el servidor que este dispositivo es quien dice ser? Hay tres respuestas, de peor a mejor:
- Usuario y contraseña compartidos por toda la flota. Un dispositivo comprometido compromete a todos. Solo aceptable en un laboratorio.
- Credencial única por dispositivo, guardada en la flash. Mejor: revocar uno no afecta al resto. Sigue siendo extraíble por quien tenga el aparato en la mano.
- Certificado X.509 con clave privada generada dentro de un elemento seguro. Chips como el ATECC608 generan el par de claves internamente y nunca exponen la privada: solo firman lo que se les pide. Aunque desuelden el chip y lean la flash completa, no obtienen la clave. En el mundo Nerves este patrón se materializa con NervesKey, que empaqueta el ATECC608 con la provisión de certificados.
El aprovisionamiento es el momento en que el dispositivo obtiene su identidad. Ocurre en fábrica, en una estación de programación con acceso a la CA, y es el único punto de toda la vida del aparato donde hay un secreto en tránsito. Después de eso el dispositivo se autentica solo, y el backend solo necesita confiar en la CA que firmó su certificado.
Higiene mínima que no se negocia
- Cero secretos en el repositorio —ni claves, ni tokens, ni certificados de producción—, y superficie mínima: sin SSH abierto en producción salvo razón explícita, sin Telnet nunca, sin servicios escuchando en interfaces que no se usan.
- Reloj correcto. La validación de certificados depende de la fecha. Un dispositivo sin RTC que arranca en 1970 va a rechazar todos los certificados válidos. Se resuelve con NTP temprano en el arranque o con un RTC por I2C como el DS3231.
- Registro sin datos sensibles, porque los logs viajan y se almacenan, y reconexión educada con retardo exponencial, tope y componente aleatorio: mil dispositivos reintentando cada segundo tras un corte tumban el broker que estaban esperando.
Gestión de flota
Con un prototipo, actualizar significa conectar un cable. Con mil dispositivos en terreno, actualizar es una operación de riesgo: un firmware malo distribuido a toda la flota deja mil ladrillos, y recuperarlos cuesta más que el proyecto entero. La disciplina que evita ese desastre se apoya en cuatro pilares.
1. Inventario e identidad
Cada dispositivo necesita un identificador estable e irrepetible, derivado del hardware y no de la configuración: número de serie del SoC, MAC, o el número de serie del elemento seguro. El backend mantiene, para cada uno, al menos: identificador, versión de firmware activa, última conexión, versión de hardware, ubicación lógica y estado del despliegue.
2. Actualización OTA con particiones A/B
El esquema que hace la actualización recuperable es el de dos particiones de sistema. El firmware nuevo se escribe siempre en la partición inactiva, mientras el sistema sigue corriendo desde la activa. Solo al final se cambia el puntero de arranque y se reinicia. Si el arranque nuevo no logra confirmarse, el gestor vuelve a la partición anterior.
stateDiagram-v2
[*] --> CorriendoA: arranque normal desde A
CorriendoA --> DescargandoB: el servidor anuncia firmware nuevo
DescargandoB --> VerificandoB: descarga completa
VerificandoB --> CorriendoA: firma invalida o hash distinto
VerificandoB --> EscritaB: firma valida
EscritaB --> MarcadaB: se apunta el arranque a B
MarcadaB --> ReinicioPendiente: se agenda el reinicio
ReinicioPendiente --> ArrancandoB: reinicio
ArrancandoB --> ValidandoB: el sistema levanta
ValidandoB --> CorriendoB: la app se conecta y llama a validar
ValidandoB --> ArrancandoA: watchdog expira sin validacion
ArrancandoA --> CorriendoA: rollback automatico a la particion anterior
CorriendoB --> DescargandoA: siguiente actualizacion
CorriendoB --> [*]
El punto crítico de ese diagrama es la transición ValidandoB --> ArrancandoA. La actualización no se considera exitosa por haber arrancado: se considera exitosa cuando la aplicación demuestra que funciona —típicamente, que logró conectarse al servidor— y recién entonces confirma el nuevo firmware. Un temporizador de vigilancia en el gestor de arranque hace el resto.
En Nerves esta mecánica está integrada. El firmware se empaqueta en un archivo .fw con mix firmware, se aplica con fwup sobre la partición inactiva, y la validación se hace desde el código:
defmodule Planta.Salud do
@moduledoc """
Confirma el firmware recien instalado solo despues de comprobar
que las funciones criticas del dispositivo estan operativas.
"""
use GenServer
require Logger
@gracia_ms 60_000
def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
@impl true
def init(:ok) do
Process.send_after(self(), :evaluar, @gracia_ms)
{:ok, %{}}
end
@impl true
def handle_info(:evaluar, state) do
if red_operativa?() and sensores_operativos?() do
Logger.info("Comprobaciones superadas: confirmando el firmware")
Nerves.Runtime.validate_firmware()
else
Logger.error("Comprobaciones fallidas: no se confirma; el watchdog hara rollback")
end
{:noreply, state}
end
defp red_operativa? do
case :gen_tcp.connect(~c"mqtt.acme.cl", 8883, [:binary, active: false], 5_000) do
{:ok, socket} -> :gen_tcp.close(socket) == :ok
{:error, _} -> false
end
end
defp sensores_operativos?, do: match?({:ok, _}, Planta.Sensor.leer())
end
NervesHub es el servidor de gestión de flota del ecosistema: mantiene el inventario, autentica dispositivos por certificado, permite agruparlos, publicar firmware firmado y desplegarlo por grupos. El dispositivo se conecta con la dependencia nerves_hub_link, que mantiene un canal permanente hacia el servidor y recibe la orden de actualización por ahí. Las firmas se generan con pares de claves de fwup, y el dispositivo rechaza cualquier imagen que no valide contra la clave pública embebida en su propio firmware.
3. Despliegue por etapas
Nunca se actualiza toda la flota de una vez. La secuencia sana:
| Etapa | Alcance | Qué se observa antes de avanzar |
|---|---|---|
| Banco | 2 a 5 dispositivos de laboratorio | Arranque, sensores, conexión, consumo |
| Canario | 1 % de la flota, mezclando versiones de hardware | Tasa de reconexión, errores nuevos, memoria |
| Anillo 1 | 10 %, sitios de fácil acceso físico | Estabilidad durante al menos un ciclo diario completo |
| Anillo 2 | 50 % | Métricas agregadas comparadas con el grupo sin actualizar |
| General | 100 % | — |
La métrica que decide si se avanza o se aborta es simple y brutal: el porcentaje de dispositivos que confirmaron el firmware nuevo y siguen reportando. Si el canario baja de ese umbral, el despliegue se detiene.
4. Observabilidad
Un dispositivo debe reportar, además de sus mediciones, su propia salud: versión de firmware, tiempo encendido, memoria libre, calidad de señal, temperatura interna, contador de reinicios y de reconexiones. Ese canal estado/salud es el que permite detectar una regresión antes de que el cliente llame. En el capítulo 11 vamos a ver cómo se convierte en gráficos y alertas.
El bus I2C: dos cables y muchos matices
Bajemos ahora al otro extremo del sistema. Toda la arquitectura anterior transporta números que alguien tuvo que medir, y esa medición viaja del sensor al procesador por un bus. El más común en electrónica de baja velocidad es I2C, y conocerlo bien es la diferencia entre integrar un sensor en veinte minutos o pasar dos días con un osciloscopio.
I2C —Inter-Integrated Circuit, que se pronuncia “i cuadrado c” o “i dos c”— es un bus serie síncrono desarrollado por Philips Semiconductors en 1982 para que los chips dentro de un televisor conversaran con la menor cantidad de pistas posible. Su longevidad se explica por una propiedad: con solo dos cables se conectan hasta 112 dispositivos direccionables.
Sus características esenciales:
- Dos líneas:
SDApara datos ySCLpara el reloj. Más masa común, que es un cable pero no es una línea del bus. - Síncrono: el reloj lo genera el controlador. No hay que acordar velocidades de antemano como en UART.
- Half-duplex: se transmite en una dirección a la vez sobre la misma línea de datos.
- Direccionable: cada periférico responde a una dirección propia; el controlador la anuncia al inicio de cada transacción.
- Multi-controlador: pueden coexistir varios controladores en el mismo bus, con un arbitraje que resuelve las colisiones sin perder datos. Eso sí, es de corto alcance: pensado para centímetros dentro de una placa, no para metros entre gabinetes.
Sobre la terminología: la nomenclatura histórica era “maestro/esclavo”. La especificación actual de NXP y la mayoría de las bibliotecas modernas usan controlador y periférico (controller y target). Vas a encontrar ambas en documentación y en nombres de registros.
Open-drain: por qué el bus necesita resistencias
Este es el concepto que explica la mitad de los problemas de I2C. Los pines de SDA y SCL no empujan a nivel alto. Cada dispositivo del bus puede hacer solo dos cosas: conectar la línea a masa —tirarla a nivel bajo— o soltarla y quedar en alta impedancia. Esa configuración se llama colector abierto o drenador abierto (open-drain).
Si nadie empuja hacia arriba, una línea soltada por todos queda flotando en un valor indefinido. Por eso el bus requiere resistencias de polarización, las pull-ups, conectadas entre cada línea y la alimentación. Ellas son las que llevan la línea a nivel alto cuando nadie la está tirando abajo.
flowchart LR
VDD["VDD 3,3 V"]
Rp1["Rp SDA<br/>4,7 kohm"]
Rp2["Rp SCL<br/>4,7 kohm"]
SDA(["Linea SDA"])
SCL(["Linea SCL"])
C["Controlador<br/>Raspberry Pi"]
P1["Periferico 0x76<br/>BME280"]
P2["Periferico 0x68<br/>MPU6050"]
P3["Periferico 0x3C<br/>Pantalla OLED"]
GND["GND comun"]
VDD --- Rp1 --> SDA
VDD --- Rp2 --> SCL
C --- SDA
C --- SCL
P1 --- SDA
P1 --- SCL
P2 --- SDA
P2 --- SCL
P3 --- SDA
P3 --- SCL
C --- GND
P1 --- GND
P2 --- GND
P3 --- GND
Esta topología tiene tres consecuencias directas que conviene interiorizar:
- El nivel bajo gana siempre. Si cualquier dispositivo tira la línea a masa, la línea está baja aunque los demás la hayan soltado. Es un AND cableado, y es exactamente lo que permite el arbitraje y el estiramiento de reloj.
- La velocidad de subida depende de la resistencia y de la capacitancia. Bajar la línea es rápido, porque un transistor la conecta a masa; subirla es un proceso RC en el que la pull-up debe cargar toda la capacidad parásita del bus, y ese tiempo de subida es el que limita la frecuencia máxima.
- Las pull-ups se suman en paralelo. Cada módulo comercial suele traer las suyas. Con un módulo, 4,7 kΩ. Con cuatro módulos de 4,7 kΩ cada uno, la resistencia efectiva cae a 1,2 kΩ; con seis, a 780 Ω, y el bus deja de funcionar porque los dispositivos ya no pueden tirar la línea lo bastante abajo.
Cómo se calcula el valor de la pull-up
La especificación acota el valor por ambos extremos. Vale la pena hacer los números al menos una vez.
Cota inferior, impuesta por la corriente que el transistor de salida puede absorber manteniendo un nivel bajo válido. La especificación fija una corriente de descarga de 3 mA y un nivel bajo máximo de 0,4 V:
Rp_min = (VDD - VOL_max) / IOL
Con VDD = 3,3 V: Rp_min = (3,3 - 0,4) / 0,003 = 967 ohm -> aprox. 1 kohm
Con VDD = 5,0 V: Rp_min = (5,0 - 0,4) / 0,003 = 1533 ohm -> aprox. 1,6 kohm
Cota superior, impuesta por el tiempo de subida máximo permitido a esa velocidad y por la capacitancia total del bus:
Rp_max = t_r / (0,8473 x Cb)
A 100 kHz, t_r = 1000 ns, Cb = 200 pF: Rp_max = 5,9 kohm
A 400 kHz, t_r = 300 ns, Cb = 200 pF: Rp_max = 1,8 kohm
A 400 kHz, t_r = 300 ns, Cb = 100 pF: Rp_max = 3,5 kohm
A 1 MHz, t_r = 120 ns, Cb = 100 pF: Rp_max = 1,4 kohm
De ahí sale toda la sabiduría popular sobre el tema:
- 4,7 kΩ es el valor por defecto porque cae dentro del rango válido a 100 kHz con un bus corto y pocos dispositivos; a 400 kHz muchas veces hay que bajar a 2,2 kΩ o 1,8 kΩ, sobre todo si el cableado suma capacitancia.
- La capacitancia máxima del bus es de 400 pF en modo estándar y rápido. Un cable de tira plana aporta del orden de 50 a 100 pF por metro, así que dos metros de cable ya consumen la mitad del presupuesto.
- Si el bus no arranca con el cableado largo, primero baja la velocidad, después baja la resistencia. Bajar la frecuencia relaja el tiempo de subida permitido y suele ser la solución de un minuto.
Direcciones: el mapa completo
Una dirección I2C estándar tiene 7 bits, lo que da 128 combinaciones. La especificación reserva 16 de ellas, dejando 112 direcciones utilizables, del 0x08 al 0x77.
| Rango | Bits | Uso reservado |
|---|---|---|
0x00 | 0000 000 | Llamada general al escribir; byte de arranque al leer |
0x01 | 0000 001 | Dirección CBUS, del bus predecesor |
0x02 | 0000 010 | Reservada para otro formato de bus |
0x03 | 0000 011 | Reservada para usos futuros |
0x04 a 0x07 | 0000 1XX | Código de controlador de modo alta velocidad |
0x08 a 0x77 | — | Disponibles para periféricos |
0x78 a 0x7B | 1111 0XX | Prefijo de direccionamiento de 10 bits |
0x7C a 0x7F | 1111 1XX | Reservadas; incluye el identificador de dispositivo |
Existe también un modo de 10 bits que amplía el espacio a más de mil direcciones, pero es raro en la práctica: casi ningún sensor comercial lo implementa.
La confusión de 7 bits contra 8 bits
Es el error de lectura de hoja de datos más frecuente del mundo embebido. En el cable, el primer byte de una transacción contiene la dirección de 7 bits desplazada un lugar a la izquierda, y el bit menos significativo indica lectura o escritura:
Direccion 7 bits: 0x68 = 110 1000
Byte en el cable, escritura: 1101 0000 = 0xD0
Byte en el cable, lectura: 1101 0001 = 0xD1
Algunas hojas de datos publican 0x68, otras publican 0xD0 y 0xD1, y algunas publican las tres. Las bibliotecas modernas —Circuits.I2C en Elixir, Wire en Arduino, smbus en Python— reciben siempre la dirección de 7 bits y hacen el desplazamiento internamente. Si escaneas el bus y aparece 0x68 pero la hoja dice 0xD0, no hay ningún misterio: es el mismo chip. Y si el escaneo te muestra una dirección que es exactamente el doble de la que esperabas, ya sabes qué pasó.
Direcciones comunes y sus colisiones
| Dispositivo | Función | Dirección | Cómo cambiarla |
|---|---|---|---|
| BME280 / BMP280 | Temperatura, humedad, presión | 0x76 o 0x77 | Pin SDO a masa o a VDD |
| MPU6050 / MPU9250 | Acelerómetro y giróscopo | 0x68 o 0x69 | Pin AD0 |
| DS3231 / DS1307 | Reloj de tiempo real | 0x68 fija | No se puede |
| SSD1306 | Pantalla OLED | 0x3C o 0x3D | Puente en la placa |
| PCF8574 | Expansor de 8 entradas/salidas | 0x20 a 0x27 | Tres pines A0, A1, A2 |
| MCP23017 | Expansor de 16 entradas/salidas | 0x20 a 0x27 | Tres pines de dirección |
| ADS1115 | Conversor analógico-digital de 16 bits | 0x48 a 0x4B | Pin ADDR a VDD, GND, SDA o SCL |
| INA219 | Medidor de corriente y potencia | 0x40 a 0x4F | Dos pines de dirección |
| VL53L0X | Distancia por tiempo de vuelo | 0x29 fija de fábrica | Se reprograma por software al arrancar |
| TCA9548A | Multiplexor de bus I2C | 0x70 a 0x77 | Tres pines de dirección |
La colisión más famosa de la tabla es evidente: el MPU6050 y el DS3231 comparten el 0x68. Un reloj y una unidad inercial en el mismo proyecto es una combinación normalísima, y el bus simplemente no funciona. Hay cuatro salidas, en orden de preferencia:
- Cambiar la dirección de uno con su pin de configuración. Aquí, el AD0 del MPU6050 a VDD lo mueve a
0x69. - Reprogramar la dirección por software al arrancar, si el chip lo permite, como el VL53L0X cuando se usan varios sensores de distancia.
- Usar un multiplexor como el TCA9548A, que expone ocho buses aislados y selecciona el activo escribiendo un byte de máscara, o bien un segundo bus físico: una Raspberry Pi puede habilitar buses adicionales con superposiciones de árbol de dispositivos y un ESP32 tiene dos controladores I2C por hardware.
La trama, bit por bit
Una transacción I2C se compone de condiciones y bytes bien definidos. Las condiciones se distinguen de los datos porque son los únicos momentos en que SDA cambia mientras SCL está en alto:
- START: SDA baja mientras SCL está alto. Marca el inicio y despierta a todos los periféricos, que se ponen a escuchar la dirección.
- Byte de dirección: 7 bits de dirección más el bit R/W. Un
0significa escritura hacia el periférico; un1, lectura desde él. - ACK / NACK: después de cada byte, el receptor dispone de un noveno pulso de reloj para tirar SDA a nivel bajo. Si lo hace, es un ACK. Si deja la línea alta —por la pull-up—, es un NACK. Un NACK a la dirección significa “nadie con ese número está presente”.
- Bytes de datos: 8 bits, siempre el más significativo primero, cada uno seguido de su bit de reconocimiento.
- Repeated START: un nuevo START sin STOP intermedio. Cambia el sentido de la conversación sin soltar el bus, lo que impide que otro controlador se meta en medio.
- STOP: SDA sube mientras SCL está alto. Libera el bus.
El patrón dominante en sensores es escribir el número de registro y luego leer su contenido, con un repeated START entre medio:
sequenceDiagram
autonumber
participant C as Controlador
participant B as Bus SDA/SCL
participant P as Periferico 0x68
C->>B: START
C->>B: 0x68 + bit W
P-->>C: ACK
C->>B: 0x3B, registro ACCEL_XOUT_H
P-->>C: ACK
Note over C,P: repeated START, no se suelta el bus
C->>B: START repetido
C->>B: 0x68 + bit R
P-->>C: ACK
P->>C: dato 1, byte alto de X
C-->>P: ACK
P->>C: dato 2, byte bajo de X
C-->>P: ACK
P->>C: dato 3, byte alto de Y
C-->>P: ACK
P->>C: dato 4, byte bajo de Y
C-->>P: ACK
P->>C: dato 5, byte alto de Z
C-->>P: ACK
P->>C: dato 6, byte bajo de Z
C-->>P: NACK, ultimo byte
C->>B: STOP
El detalle del NACK final confunde a mucha gente: el controlador responde con NACK al último byte que quiere recibir. No es un error, es la señal convenida de “ya no me mandes más”. Si el controlador respondiera ACK, el periférico seguiría cargando el siguiente byte del puntero de registro.
Dos mecanismos adicionales completan el cuadro:
- Clock stretching: un periférico lento puede mantener SCL en bajo después de un ACK para decir “espérame, todavía estoy calculando”. El controlador debe esperar a que la línea suba. Es una función opcional del protocolo, y una fuente clásica de incompatibilidades: el hardware I2C del BCM2835 de las Raspberry Pi antiguas tiene un fallo conocido con el estiramiento de reloj en ciertas condiciones, y la solución habitual es bajar la frecuencia del bus o usar un bus I2C por software sobre GPIO.
- Arbitraje: si dos controladores empiezan a hablar a la vez, cada uno vigila la línea mientras transmite. El primero que intente poner un
1y vea un0sabe que perdió, se calla de inmediato y reintenta después. Como el nivel bajo gana, el mensaje del ganador viaja íntegro y no se pierde nada.
Velocidades disponibles
| Modo | Frecuencia máxima de SCL | Tiempo de subida máximo | Notas |
|---|---|---|---|
| Standard | 100 kHz | 1000 ns | El más compatible; el valor por defecto en Linux |
| Fast | 400 kHz | 300 ns | Soportado por casi todos los sensores actuales |
| Fast Mode Plus | 1 MHz | 120 ns | Requiere pull-ups más bajas y bus muy corto |
| High Speed | 3,4 MHz | 40 ns | Necesita un código de controlador especial y pull-ups activas |
| Ultra Fast | 5 MHz | — | Unidireccional, sin ACK; muy poco frecuente |
Todos los dispositivos del bus tienen que tolerar la frecuencia elegida. La velocidad efectiva del bus es la del dispositivo más lento conectado, y un chip antiguo de 100 kHz en un bus a 400 kHz produce fallos intermitentes que parecen aleatorios.
Los conectores estandarizados
Cablear I2C en protoboard con cuatro cables sueltos es la principal fuente de falsos contactos. La industria de la electrónica educativa resolvió el problema con conectores JST de cuatro pines que llevan VDD, GND, SDA y SCL en un orden fijo, y que se pueden encadenar en cadena de margarita:
| Estándar | Origen | Conector | Voltaje | Compatibilidad |
|---|---|---|---|---|
| Qwiic | SparkFun | JST SH de 4 pines, paso 1 mm | Solo 3,3 V | Eléctricamente compatible con STEMMA QT |
| STEMMA QT | Adafruit | JST SH de 4 pines, paso 1 mm | 3,3 V, con adaptación de nivel en muchas placas | Intercambiable con Qwiic |
| STEMMA | Adafruit | JST PH de 4 pines, paso 2 mm | 3,3 V y 5 V | Conector distinto, requiere cable adaptador |
| Grove I2C | Seeed Studio | Conector Grove de 4 pines | 3,3 V y 5 V | Requiere cable adaptador hacia Qwiic |
El orden de los pines está fijado por el estándar, así que dos placas de fabricantes distintos con el mismo conector se enchufan sin pensar. La ganancia real es de fiabilidad: un conector con retención mecánica no se suelta cuando el robot vibra.
I2C en Linux y en Elixir
En una Raspberry Pi con Linux o con Nerves, cada bus aparece como un archivo de dispositivo /dev/i2c-N. El bus expuesto en el conector de 40 pines es normalmente i2c-1, con SDA en el pin físico 3 (GPIO 2) y SCL en el pin físico 5 (GPIO 3).
En Raspberry Pi OS el bus se habilita en /boot/firmware/config.txt:
# Habilita el controlador I2C principal
dtparam=i2c_arm=on
# Sube la velocidad del bus a 400 kHz
dtparam=i2c_arm_baudrate=400000
# Bus I2C por software sobre GPIO 23 y 24, util cuando el hardware
# no tolera el estiramiento de reloj de algun periferico
dtoverlay=i2c-gpio,bus=3,i2c_gpio_sda=23,i2c_gpio_scl=24
Herramientas de línea de comandos
El paquete i2c-tools es lo primero que se instala al depurar. La bandera -y evita la confirmación interactiva:
# Instalar en Debian, Ubuntu o Raspberry Pi OS
sudo apt install i2c-tools
# Listar los buses disponibles
i2cdetect -l
# Escanear el bus 1: muestra todas las direcciones que responden
i2cdetect -y 1
# Leer un registro: bus 1, dispositivo 0x68, registro 0x75 (WHO_AM_I)
i2cget -y 1 0x68 0x75
# Escribir un byte: despertar el MPU6050 poniendo 0x00 en PWR_MGMT_1
i2cset -y 1 0x68 0x6B 0x00
# Volcar los 256 registros del dispositivo
i2cdump -y 1 0x68
Una salida típica de i2cdetect -y 1 con dos sensores presentes:
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- 3c -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- --
70: -- -- -- -- -- -- 76 --
Aquí hay una pantalla OLED en 0x3C, una unidad inercial en 0x68 y un sensor ambiental en 0x76. Si el escaneo sale completamente vacío, el problema es de hardware —alimentación, masa común o pull-ups— y no vale la pena escribir una sola línea de código todavía.
La biblioteca Circuits.I2C
En Elixir, el acceso al bus lo provee circuits_i2c, parte de la familia de bibliotecas de hardware del ecosistema Nerves. Funciona igual en una Raspberry Pi con Nerves que en una con Linux completo.
# mix.exs
defp deps do
[{:circuits_i2c, "~> 2.0"}]
end
La superficie de la API es pequeña y directa:
# Buses disponibles
iex> Circuits.I2C.bus_names()
["i2c-1"]
# Escaneo equivalente a i2cdetect, desde IEx
iex> Circuits.I2C.detect_devices()
Devices on I2C bus "i2c-1":
* 60 (0x3C)
* 104 (0x68)
* 118 (0x76)
3 devices detected on 1 I2C buses
# Abrir el bus
iex> {:ok, bus} = Circuits.I2C.open("i2c-1")
{:ok, #Reference<...>}
# Escribir: registro 0x6B con valor 0x00
iex> Circuits.I2C.write(bus, 0x68, <<0x6B, 0x00>>)
:ok
# Escribir y leer en una sola transaccion con repeated START
iex> Circuits.I2C.write_read(bus, 0x68, <<0x75>>, 1)
{:ok, <<0x68>>}
# Leer sin escribir antes, desde el puntero actual
iex> Circuits.I2C.read(bus, 0x68, 6)
{:ok, <<0, 12, 255, 240, 64, 8>>}
# Cerrar
iex> Circuits.I2C.close(bus)
:ok
La función clave es write_read/4: ejecuta la escritura del registro y la lectura del dato en una sola transacción con repeated START, sin soltar el bus entre medio. Hacerlo con write/3 seguido de read/3 funciona en un bus con un solo controlador, pero abre una ventana donde otro proceso podría intercalar su propia transacción y dejar el puntero de registro en otro lugar.
Existen además variantes con signo de exclamación —read!/3, write!/3, write_read!/4— que devuelven el dato directamente y levantan una excepción ante el error, cómodas en IEx y desaconsejadas en código de producción, donde queremos manejar {:error, :i2c_nak} explícitamente.
Un driver completo: MPU6050
Vamos a escribir el driver entero de un acelerómetro y giróscopo MPU6050, envuelto en un GenServer que serializa el acceso al bus. Esta es la pieza que faltaba para cerrar el círculo del capítulo: el dato que después publicamos por MQTT.
defmodule Planta.Sensor.MPU6050 do
@moduledoc """
Driver del MPU6050: acelerometro de 3 ejes, giroscopo de 3 ejes y
sensor de temperatura interno, sobre I2C.
El GenServer serializa el acceso al bus: aunque varios procesos pidan
lecturas a la vez, las transacciones I2C ocurren de a una.
"""
use GenServer
require Logger
# Registros segun la hoja de datos del MPU-6000/MPU-6050
@reg_pwr_mgmt_1 0x6B
@reg_smplrt_div 0x19
@reg_config 0x1A
@reg_gyro_config 0x1B
@reg_accel_config 0x1C
@reg_accel_xout_h 0x3B
@reg_who_am_i 0x75
# Factores de escala para los fondos de escala por defecto:
# acelerometro +-2 g y giroscopo +-250 grados por segundo
@accel_lsb_por_g 16_384.0
@gyro_lsb_por_dps 131.0
@who_am_i_esperado 0x68
# --- API publica ---
def start_link(opts) do
nombre = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: nombre)
end
@doc "Lee acelerometro, giroscopo y temperatura en una sola transaccion."
def leer(servidor \\ __MODULE__) do
GenServer.call(servidor, :leer)
end
# --- Callbacks ---
@impl true
def init(opts) do
bus_name = Keyword.get(opts, :bus, "i2c-1")
address = Keyword.get(opts, :address, 0x68)
with {:ok, bus} <- Circuits.I2C.open(bus_name),
:ok <- verificar_identidad(bus, address),
:ok <- configurar(bus, address) do
Logger.info("MPU6050 inicializado en #{bus_name} direccion 0x#{Integer.to_string(address, 16)}")
{:ok, %{bus: bus, address: address}}
else
{:error, motivo} ->
Logger.error("No se pudo inicializar el MPU6050: #{inspect(motivo)}")
{:stop, motivo}
end
end
@impl true
def handle_call(:leer, _from, state) do
# 14 bytes consecutivos desde 0x3B:
# accel X, Y, Z (6), temperatura (2), gyro X, Y, Z (6)
case Circuits.I2C.write_read(state.bus, state.address, <<@reg_accel_xout_h>>, 14) do
{:ok, datos} -> {:reply, {:ok, decodificar(datos)}, state}
{:error, motivo} -> {:reply, {:error, motivo}, state}
end
end
@impl true
def terminate(_reason, %{bus: bus}) do
Circuits.I2C.close(bus)
:ok
end
# --- Internals ---
defp verificar_identidad(bus, address) do
case Circuits.I2C.write_read(bus, address, <<@reg_who_am_i>>, 1) do
{:ok, <<@who_am_i_esperado>>} ->
:ok
{:ok, <<otro>>} ->
{:error, {:chip_inesperado, otro}}
{:error, :i2c_nak} ->
{:error, :sin_respuesta_en_esa_direccion}
otro ->
otro
end
end
defp configurar(bus, address) do
with :ok <- escribir(bus, address, @reg_pwr_mgmt_1, 0x00),
# Filtro pasabajos digital en 44 Hz: reduce el ruido mecanico
:ok <- escribir(bus, address, @reg_config, 0x03),
# Divisor de frecuencia de muestreo: 1 kHz / (1 + 9) = 100 Hz
:ok <- escribir(bus, address, @reg_smplrt_div, 0x09),
# Giroscopo a +-250 dps
:ok <- escribir(bus, address, @reg_gyro_config, 0x00),
# Acelerometro a +-2 g
:ok <- escribir(bus, address, @reg_accel_config, 0x00) do
:ok
end
end
defp escribir(bus, address, registro, valor) do
Circuits.I2C.write(bus, address, <<registro, valor>>)
end
# Todos los valores son enteros con signo de 16 bits, big-endian.
defp decodificar(<<
ax::signed-big-16,
ay::signed-big-16,
az::signed-big-16,
temp::signed-big-16,
gx::signed-big-16,
gy::signed-big-16,
gz::signed-big-16
>>) do
%{
acel_g: %{
x: ax / @accel_lsb_por_g,
y: ay / @accel_lsb_por_g,
z: az / @accel_lsb_por_g
},
giro_dps: %{
x: gx / @gyro_lsb_por_dps,
y: gy / @gyro_lsb_por_dps,
z: gz / @gyro_lsb_por_dps
},
# Formula de la hoja de datos para el sensor interno
temperatura_c: temp / 340.0 + 36.53
}
end
end
Tres decisiones de diseño que vale la pena señalar:
- El pattern matching de binarios hace el trabajo pesado. Los catorce bytes se descomponen en siete enteros con signo big-endian en una sola cláusula. En C esto serían catorce desplazamientos y máscaras, con las conversiones de signo mal hechas la primera vez.
init/1falla ruidosamente si el chip no está o responde otro identificador: el supervisor decide qué hacer y el driver no finge estar bien. Y elGenServeres el punto de serialización, porque el bus I2C es un recurso compartido y no reentrante; envolverlo en un proceso convierte la exclusión mutua en el patrón que OTP ya resuelve.
Usar un multiplexor cuando las direcciones chocan
Si necesitas tres sensores idénticos, un TCA9548A resuelve el conflicto. Se le escribe un solo byte cuyos bits indican qué canales quedan conectados al bus principal:
defmodule Planta.Sensor.Mux do
@moduledoc """
Control del multiplexor I2C TCA9548A.
Se escribe un unico byte al chip: cada bit habilita uno de los ocho
canales. Escribir 0x00 desconecta todos los canales.
"""
@direccion_por_defecto 0x70
@doc "Selecciona un unico canal, de 0 a 7."
def seleccionar(bus, canal, direccion \\ @direccion_por_defecto)
when canal in 0..7 do
Circuits.I2C.write(bus, direccion, <<Bitwise.bsl(1, canal)>>)
end
@doc "Desconecta todos los canales del bus principal."
def apagar(bus, direccion \\ @direccion_por_defecto) do
Circuits.I2C.write(bus, direccion, <<0x00>>)
end
@doc """
Ejecuta una funcion con un canal seleccionado y despues lo desconecta,
aunque la funcion levante una excepcion.
"""
def con_canal(bus, canal, fun, direccion \\ @direccion_por_defecto) do
:ok = seleccionar(bus, canal, direccion)
try do
fun.()
after
apagar(bus, direccion)
end
end
end
Con eso, tres sensores de distancia que salen de fábrica todos en 0x29 conviven sin conflicto: se recorre 0..2 seleccionando el canal antes de cada write_read/4.
Cerrando el círculo: del bus al broker
Con el driver y el cliente MQTT listos, el módulo que los une es breve. Este es el sistema completo del capítulo funcionando de punta a punta:
defmodule Planta.Sensor do
@moduledoc "Fachada de lectura usada por el productor de telemetria."
def leer do
case Planta.Sensor.MPU6050.leer() do
{:ok, m} ->
# La magnitud del vector sirve para detectar golpes o vibracion.
magnitud = :math.sqrt(m.acel_g.x ** 2 + m.acel_g.y ** 2 + m.acel_g.z ** 2)
{:ok,
%{
temperatura_c: Float.round(m.temperatura_c, 2),
acel_g: m.acel_g,
magnitud_g: Float.round(magnitud, 4)
}}
{:error, motivo} ->
{:error, motivo}
end
end
end
El árbol de supervisión final queda así, con el driver del bus arrancando antes que la conexión de red, y la telemetría al final porque depende de ambos:
children = [
{Planta.Sensor.MPU6050, bus: "i2c-1", address: 0x68},
{Tortoise311.Connection, conexion_mqtt(device_id)},
Planta.Salud,
Planta.Telemetria
]
Supervisor.start_link(children, strategy: :rest_for_one, name: Planta.Supervisor)
La estrategia :rest_for_one no es casual: si el driver del sensor muere, no tiene sentido que la telemetría siga viva publicando lecturas de un bus cerrado, así que se reinician también los hijos que vienen después. La conexión MQTT, en cambio, puede caerse y volver sin arrastrar al driver.
Comparación de I2C con los otros buses
| Característica | I2C | SPI | UART | 1-Wire | CAN |
|---|---|---|---|---|---|
| Cables de señal | 2 | 3 más una selección por dispositivo | 2 | 1 más masa | 2 diferenciales |
| Velocidad típica | 100 kHz a 1 MHz | 1 a 50 MHz | 9,6 a 115,2 kbps | 15 kbps | 125 kbps a 1 Mbps |
| Direccionamiento | Sí, 7 bits en el bus | No; una línea física por periférico | No; punto a punto | Sí, 64 bits únicos | Por identificador de mensaje |
| Dúplex | Half | Full | Full | Half | Half |
| Número de dispositivos | Hasta 112 | Limitado por pines libres | 2 | Decenas | Más de 100 nodos |
| Distancia | Centímetros | Centímetros | Metros, o kilómetros con RS-485 | Decenas de metros | Cientos de metros |
| Confirmación por hardware | Sí, bit ACK | No | No | Sí, pulso de presencia | Sí, con retransmisión |
| Complejidad de cableado | Muy baja | Media, crece con los dispositivos | Muy baja | Mínima | Media, requiere terminaciones |
| Uso característico | Sensores, RTC, pantallas pequeñas | Pantallas grandes, memoria flash, ADC rápidos | Consola, módulos GPS y GSM | Termómetros DS18B20 | Automotriz e industrial |
Elegir es casi mecánico: I2C cuando hay muchos periféricos lentos y pocos pines; SPI cuando importa el caudal; UART para módulos que hablan por texto; 1-Wire cuando el cable es el problema; CAN cuando el ambiente es eléctricamente hostil y la fiabilidad es un requisito duro.
Errores comunes y cómo salir de ellos
En la parte IoT y MQTT
| Síntoma | Causa | Solución |
|---|---|---|
| El dispositivo se conecta y se desconecta en bucle cada pocos segundos | Dos clientes usando el mismo client_id; el broker expulsa al anterior | Derivar el identificador del número de serie del hardware, nunca del nombre del proyecto |
| Un suscriptor nuevo recibe un evento que ocurrió hace días | Evento publicado con retain: true | Retener solo el estado; borrar el retenido publicando carga útil vacía con retain: true |
| No se distingue un dispositivo apagado de uno sin novedades | No se configuró Last Will | Registrar el testamento en el CONNECT y publicar presencia retenida al conectar |
| El broker se cae después de un corte eléctrico general | Toda la flota reconectando simultáneamente | Retardo exponencial con tope y desfase aleatorio por dispositivo |
| Los mensajes llegan duplicados | QoS 1 con reintentos del broker | Diseñar consumidores idempotentes; incluir un identificador único por mensaje |
| El TLS falla con error de certificado en dispositivos recién encendidos | Reloj del sistema en 1970; el certificado “aún no es válido” | Sincronizar por NTP antes de la primera conexión, o instalar un RTC por I2C |
| Un dispositivo comprometido publica en topics de otros | Sin ACL; toda la flota comparte credencial | Certificado por dispositivo y ACL que restrinja al prefijo propio |
| Se acabó la batería del sensor LoRaWAN en semanas en vez de años | Clase C, o intervalo de reporte demasiado corto | Clase A, agregación en el edge y reporte por excepción |
| La actualización OTA dejó dispositivos inaccesibles | Firmware escrito sobre la partición activa, sin validación posterior | Particiones A/B, firma verificada y confirmación explícita tras comprobar funciones |
En la parte I2C
| Síntoma | Causa | Solución |
|---|---|---|
i2cdetect no muestra ninguna dirección | Falta masa común entre las placas, o el módulo no está alimentado | Verificar continuidad de GND y medir VDD en el módulo con el multímetro |
i2cdetect no muestra nada y el bus nunca se habilitó | Falta dtparam=i2c_arm=on o el módulo del kernel no está cargado | Habilitar en config.txt y reiniciar; comprobar con i2cdetect -l |
| Aparece una dirección que es el doble de la esperada | Confusión entre dirección de 7 y de 8 bits | Usar siempre la de 7 bits; 0xD0 en la hoja de datos es 0x68 en el código |
| Dos dispositivos y solo uno responde | Colisión de direcciones, como MPU6050 y DS3231 en 0x68 | Mover uno con su pin de dirección, o usar un multiplexor TCA9548A |
| Funcionaba con dos módulos y falla al agregar el cuarto | Pull-ups de cada módulo en paralelo; resistencia total demasiado baja | Quitar las pull-ups de todos los módulos menos uno, desoldando o cortando el puente |
| Errores intermitentes que empeoran al mover el cable | Capacitancia excesiva por cable largo; tiempo de subida fuera de especificación | Acortar el cable, bajar a 100 kHz o reducir el valor de la pull-up |
{:error, :i2c_nak} en todas las operaciones | Nadie responde a esa dirección, o el chip está en reposo | Confirmar la dirección con i2cdetect; despertar el chip escribiendo su registro de gestión de energía |
| El bus se queda colgado y solo se recupera reiniciando | Un periférico quedó a medio ciclo tirando SDA a nivel bajo | Generar hasta nueve pulsos de reloj manuales sobre SCL para liberar la línea, y luego un STOP |
| Lecturas correctas mezcladas con basura | Estiramiento de reloj no soportado por el controlador | Bajar la frecuencia del bus, o usar un bus I2C por software con dtoverlay=i2c-gpio |
| Un dispositivo de 5 V no responde en un bus de 3,3 V | Umbrales lógicos incompatibles | Adaptador de nivel bidireccional para colector abierto, nunca un divisor resistivo |
| Los valores del acelerómetro son siempre cero | El chip está en reposo tras el encendido | Escribir 0x00 en PWR_MGMT_1 (0x6B) durante la inicialización |
| Los valores tienen signo invertido y saltan a 65000 | El binario se decodificó sin signo | Usar signed-big-16 en el pattern matching del binario |
El procedimiento de depuración, en orden
Cuando un bus I2C no funciona, seguir este orden ahorra horas:
- Multímetro antes que código. Medir VDD en el pin del módulo, no en la fuente, y comprobar continuidad de GND entre ambas placas. Con el bus inactivo, SDA y SCL deben estar en VDD: si están a 0 V faltan pull-ups o algo tiene la línea agarrada, y si están a un valor intermedio hay un corto o una adaptación de nivel mal hecha.
- Escanear.
i2cdetect -y 1. Si no aparece nadie, el problema sigue siendo eléctrico y no vale la pena tocar el software. - Leer el registro de identificación. Casi todos los chips tienen uno con un valor fijo conocido. Si
WHO_AM_Iresponde lo que dice la hoja de datos, la comunicación básica está sana y el resto es software. - Bajar la velocidad. Si el escaneo es intermitente,
dtparam=i2c_arm_baudrate=100000descarta problemas de tiempo de subida en un solo paso. - Analizador lógico. Un modelo económico de ocho canales con decodificación de I2C muestra la trama real: si el START sale, si la dirección es la correcta y en qué byte exacto llega el NACK. Es la herramienta que convierte la adivinanza en observación.
Ejercicios propuestos
-
Escaneo y mapa del bus. Conecta al menos dos periféricos I2C distintos y produce el mapa del bus con
i2cdetect -y 1y conCircuits.I2C.detect_devices/0. Contrasta cada dirección encontrada con la hoja de datos del chip y anota si la hoja publica la dirección de 7 u 8 bits. -
Cálculo de pull-ups. Para un bus de 3,3 V, 400 kHz, con 60 cm de cable plano y tres módulos conectados, estima la capacitancia total y calcula el rango válido de resistencia con las dos fórmulas de la sección correspondiente. Verifica midiendo el tiempo de subida con un osciloscopio o un analizador lógico y compara con el máximo permitido de 300 ns.
-
Driver desde cero. Escribe un driver completo para un chip que no esté en este capítulo —un BME280, un ADS1115 o un MCP23017— siguiendo la estructura del
GenServerdel MPU6050: verificación de identidad eninit/1, configuración explícita de registros y decodificación con pattern matching de binarios. Incluye el manejo de{:error, :i2c_nak}. -
Resolver una colisión. Monta deliberadamente dos dispositivos con la misma dirección y resuélvelo de dos maneras distintas: primero con el pin de configuración de dirección de uno de ellos, después con un multiplexor TCA9548A. Compara el número de transacciones I2C que requiere cada lectura en ambos casos.
-
Diseño de topics y ACL. Diseña el árbol de topics de una flota de 500 sensores repartidos en 12 sitios, con telemetría, estado y comandos. Escribe las reglas de ACL que impidan que un dispositivo publique fuera de su propio prefijo y que se suscriba a los comandos de otro. Prueba las reglas contra un Mosquitto local.
-
Presencia real. Implementa presencia con Last Will y mensajes retenidos. Desconecta el cable de red del dispositivo sin cerrar la aplicación y mide cuánto tarda el broker en publicar el testamento. Relaciona ese tiempo con el valor de
keep_aliveconfigurado. -
Comparar QoS con pérdidas. Publica mil mensajes con QoS 0 y otros mil con QoS 1 mientras introduces pérdida de paquetes artificial en el enlace. Cuenta cuántos llegan en cada caso, cuántos llegan duplicados y cuántos bytes totales viajaron por la red.
-
Reconexión educada y simulacro de OTA. Implementa una política de reconexión con retardo exponencial, tope máximo y desfase aleatorio, y grafica los intentos por segundo de 200 clientes reconectando con y sin desfase. Luego, sobre un dispositivo Nerves, produce una actualización con particiones A/B que deliberadamente falle su comprobación de salud y verifica que el sistema vuelve solo a la partición anterior.
-
Sistema completo. Une todas las piezas: lee un sensor por I2C cada segundo, calcula estadísticas por ventana de un minuto en el dispositivo, publica solo el resumen por MQTT con TLS mutuo, mantén el canal de salud y agrega un comando remoto que cambie el intervalo de muestreo en caliente. Mide cuántos bytes por hora consume comparado con publicar cada muestra cruda.
Lo que viene
Con este capítulo el dispositivo quedó completo en sus dos direcciones: sabe leer el mundo por I2C y sabe conversar con el resto del sistema por MQTT, con identidad propia, canal cifrado y un camino de actualización que no lo convierte en un ladrillo. Lo que todavía no tiene es cara visible: los datos llegan al broker y ahí se quedan, sin nadie que los almacene, los grafique ni avise cuando algo se sale de rango.
Eso es exactamente lo que resolvemos en el capítulo 11, donde montamos la capa de aplicación: PlatformIO para gestionar proyectos de firmware con dependencias y múltiples placas, Home Assistant como integrador doméstico que descubre dispositivos MQTT solo, bases de series de tiempo para guardar el histórico, y Grafana para construir los tableros y las alertas que convierten un flujo de números en información accionable. El índice completo del curso está en /tecnologias/elixir-robotics/00-indice/.