IoT de punta a punta y el bus I2C sin misterios

Por: Artiko
elixirroboticaiotelectronicai2cmqttnervescircuits-i2cseguridad-iot

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:

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

EnfoqueDónde corre la lógicaLatencia típicaCosto de redCuándo conviene
Nube puraTodo en el servidor; el nodo solo transmite lecturas crudas100 ms a varios segundosAlto: cada muestra viajaPocos dispositivos, enlace barato, lógica que cambia seguido
EdgeEl nodo filtra, agrega, decide y solo reporta eventosMicrosegundos a milisegundosBajo: se transmiten resúmenesLazos de control, enlaces caros o intermitentes, privacidad
Fog / gatewayUn equipo intermedio agrupa a varios nodos y preprocesa1 a 50 msMedioRadios 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íaAlcance típicoTasa de datosConsumoTopologíaUso característico
BLE (Bluetooth Low Energy)10 a 50 m125 kbps a 2 MbpsMuy bajo; años con pila de botónEstrella, o malla con Bluetooth MeshWearables, sensores de proximidad, configuración desde el teléfono
WiFi 2,4/5 GHz30 a 100 m10 a 500 MbpsAlto; necesita alimentación o batería grandeEstrella vía punto de accesoCámaras, dispositivos con red eléctrica, prototipos con ESP32
Ethernet100 m por tramo100 Mbps a 1 GbpsAlto, pero admite PoECableadaIndustria, gateways, equipos fijos críticos
Zigbee10 a 100 m por salto250 kbpsBajoMallaDomótica, iluminación, sensores de puerta
Thread / Matter10 a 100 m por salto250 kbpsBajoMalla con IPv6 nativoDomótica moderna interoperable
LoRaWAN2 a 15 km según terreno0,3 a 50 kbpsMínimo; hasta 10 años con pilasEstrella de estrellas vía gatewaysAgricultura, medidores, sensores urbanos dispersos
NB-IoTCobertura celular20 a 250 kbpsBajoCelularMedidores en subterráneos, activos móviles
LTE-MCobertura celular300 kbps a 1 MbpsMedioCelular, con handoverRastreo de vehículos, telemetría móvil
Sigfox / LPWAN propietarias3 a 40 km100 bps, mensajes muy cortosMínimoEstrellaAlarmas 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 ejemplo planta/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ínSignificadoEjemploCoincide con
+Exactamente un nivelplanta/+/prensa-07/temperaturaplanta/linea-3/prensa-07/temperatura
#Todos los niveles restantes; solo al finalplanta/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.

QoSNombrePaquetes intercambiadosGarantíaCosto
0At most oncePUBLISHPuede perderse; nunca se duplica1 mensaje
1At least oncePUBLISH → PUBACKLlega seguro; puede duplicarse2 mensajes
2Exactly oncePUBLISH → PUBREC → PUBREL → PUBCOMPLlega exactamente una vez4 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/salud debe ser retenido; .../evento/boton-presionado jamá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 con retain: 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) o clean_start: false má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

ProtocoloTransporteModeloCabecera mínimaFuerte enDébil en
MQTTTCPPub/sub con broker2 bytesEnlaces intermitentes, muchos consumidores, comandosRequiere broker siempre disponible
CoAPUDPREST petición/respuesta4 bytesNodos muy limitados, multicast, sin brokerEntrega no garantizada sin confirmable
HTTP/RESTTCPPetición/respuestaCientos de bytesIntegración trivial, herramientas abundantesCostoso en batería y en datos
AMQPTCPColas con enrutamiento ricoDecenas de bytesGarantías transaccionales, backend empresarialPesado para un microcontrolador
Modbus TCP/RTUTCP o serialMaestro/esclavo por registros8 bytesCompatibilidad industrial universalSin seguridad, sin descubrimiento
OPC UATCPModelo de información completoGrandeSemántica industrial, seguridad integradaComplejo, 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_peer es obligatorio: sin esa opción el cliente acepta cualquier certificado y el TLS se vuelve teatro.
  • certfile y keyfile implementan TLS mutuo —el dispositivo también se identifica con un certificado propio—, que es lo que permite al broker aplicar ACL por dispositivo. Y el will se 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

AmenazaCómo se materializaContramedida concreta
Credenciales por defectoUsuario y clave iguales en toda la producciónCredencial única por dispositivo, generada en fábrica o en el primer arranque
Escucha del tráficoMQTT en el puerto 1883 sin cifrar en una red compartidaTLS 1.2 o superior siempre; el 1883 solo dentro de un túnel
Suplantación del servidorEl dispositivo confía en cualquier certificadoFijar la CA propia y usar verify_peer; comparar el nombre del servidor
Suplantación del dispositivoAlguien copia la credencial y publica datos falsosCertificado X.509 por dispositivo con clave privada no exportable
Dispositivo comprometido que ataca al restoPublica en topics de otros o inunda el brokerACL por certificado limitada a su propio prefijo; cuotas de mensajes
Extracción de secretos de la flashSe desuelda el chip y se lee la memoriaElemento seguro dedicado; cifrado de flash; deshabilitar interfaces de depuración
Firmware modificadoSe instala una imagen alteradaArranque verificado y firmas criptográficas sobre cada actualización
Acceso físico por puertos abiertosUART de consola y JTAG accesibles en la placaCerrar fusibles de depuración en producción; consola sin shell
Rollback a firmware vulnerableSe reinstala una versión antigua con un fallo conocidoContador antirreversión monótono en el gestor de arranque
Denegación por agotamientoMiles de dispositivos reconectando en tropel tras un corteReconexió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:

  1. Usuario y contraseña compartidos por toda la flota. Un dispositivo comprometido compromete a todos. Solo aceptable en un laboratorio.
  2. 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.
  3. 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:

EtapaAlcanceQué se observa antes de avanzar
Banco2 a 5 dispositivos de laboratorioArranque, sensores, conexión, consumo
Canario1 % de la flota, mezclando versiones de hardwareTasa de reconexión, errores nuevos, memoria
Anillo 110 %, sitios de fácil acceso físicoEstabilidad durante al menos un ciclo diario completo
Anillo 250 %Métricas agregadas comparadas con el grupo sin actualizar
General100 %

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.

I2CInter-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: SDA para datos y SCL para 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:

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

RangoBitsUso reservado
0x000000 000Llamada general al escribir; byte de arranque al leer
0x010000 001Dirección CBUS, del bus predecesor
0x020000 010Reservada para otro formato de bus
0x030000 011Reservada para usos futuros
0x04 a 0x070000 1XXCódigo de controlador de modo alta velocidad
0x08 a 0x77Disponibles para periféricos
0x78 a 0x7B1111 0XXPrefijo de direccionamiento de 10 bits
0x7C a 0x7F1111 1XXReservadas; 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

DispositivoFunciónDirecciónCómo cambiarla
BME280 / BMP280Temperatura, humedad, presión0x76 o 0x77Pin SDO a masa o a VDD
MPU6050 / MPU9250Acelerómetro y giróscopo0x68 o 0x69Pin AD0
DS3231 / DS1307Reloj de tiempo real0x68 fijaNo se puede
SSD1306Pantalla OLED0x3C o 0x3DPuente en la placa
PCF8574Expansor de 8 entradas/salidas0x20 a 0x27Tres pines A0, A1, A2
MCP23017Expansor de 16 entradas/salidas0x20 a 0x27Tres pines de dirección
ADS1115Conversor analógico-digital de 16 bits0x48 a 0x4BPin ADDR a VDD, GND, SDA o SCL
INA219Medidor de corriente y potencia0x40 a 0x4FDos pines de dirección
VL53L0XDistancia por tiempo de vuelo0x29 fija de fábricaSe reprograma por software al arrancar
TCA9548AMultiplexor de bus I2C0x70 a 0x77Tres 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:

  1. Cambiar la dirección de uno con su pin de configuración. Aquí, el AD0 del MPU6050 a VDD lo mueve a 0x69.
  2. Reprogramar la dirección por software al arrancar, si el chip lo permite, como el VL53L0X cuando se usan varios sensores de distancia.
  3. 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 0 significa escritura hacia el periférico; un 1, 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 1 y vea un 0 sabe 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

ModoFrecuencia máxima de SCLTiempo de subida máximoNotas
Standard100 kHz1000 nsEl más compatible; el valor por defecto en Linux
Fast400 kHz300 nsSoportado por casi todos los sensores actuales
Fast Mode Plus1 MHz120 nsRequiere pull-ups más bajas y bus muy corto
High Speed3,4 MHz40 nsNecesita un código de controlador especial y pull-ups activas
Ultra Fast5 MHzUnidireccional, 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ándarOrigenConectorVoltajeCompatibilidad
QwiicSparkFunJST SH de 4 pines, paso 1 mmSolo 3,3 VEléctricamente compatible con STEMMA QT
STEMMA QTAdafruitJST SH de 4 pines, paso 1 mm3,3 V, con adaptación de nivel en muchas placasIntercambiable con Qwiic
STEMMAAdafruitJST PH de 4 pines, paso 2 mm3,3 V y 5 VConector distinto, requiere cable adaptador
Grove I2CSeeed StudioConector Grove de 4 pines3,3 V y 5 VRequiere 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/1 falla ruidosamente si el chip no está o responde otro identificador: el supervisor decide qué hacer y el driver no finge estar bien. Y el GenServer es 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ísticaI2CSPIUART1-WireCAN
Cables de señal23 más una selección por dispositivo21 más masa2 diferenciales
Velocidad típica100 kHz a 1 MHz1 a 50 MHz9,6 a 115,2 kbps15 kbps125 kbps a 1 Mbps
DireccionamientoSí, 7 bits en el busNo; una línea física por periféricoNo; punto a puntoSí, 64 bits únicosPor identificador de mensaje
DúplexHalfFullFullHalfHalf
Número de dispositivosHasta 112Limitado por pines libres2DecenasMás de 100 nodos
DistanciaCentímetrosCentímetrosMetros, o kilómetros con RS-485Decenas de metrosCientos de metros
Confirmación por hardwareSí, bit ACKNoNoSí, pulso de presenciaSí, con retransmisión
Complejidad de cableadoMuy bajaMedia, crece con los dispositivosMuy bajaMínimaMedia, requiere terminaciones
Uso característicoSensores, RTC, pantallas pequeñasPantallas grandes, memoria flash, ADC rápidosConsola, módulos GPS y GSMTermómetros DS18B20Automotriz 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íntomaCausaSolución
El dispositivo se conecta y se desconecta en bucle cada pocos segundosDos clientes usando el mismo client_id; el broker expulsa al anteriorDerivar el identificador del número de serie del hardware, nunca del nombre del proyecto
Un suscriptor nuevo recibe un evento que ocurrió hace díasEvento publicado con retain: trueRetener solo el estado; borrar el retenido publicando carga útil vacía con retain: true
No se distingue un dispositivo apagado de uno sin novedadesNo se configuró Last WillRegistrar el testamento en el CONNECT y publicar presencia retenida al conectar
El broker se cae después de un corte eléctrico generalToda la flota reconectando simultáneamenteRetardo exponencial con tope y desfase aleatorio por dispositivo
Los mensajes llegan duplicadosQoS 1 con reintentos del brokerDiseñar consumidores idempotentes; incluir un identificador único por mensaje
El TLS falla con error de certificado en dispositivos recién encendidosReloj 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 otrosSin ACL; toda la flota comparte credencialCertificado por dispositivo y ACL que restrinja al prefijo propio
Se acabó la batería del sensor LoRaWAN en semanas en vez de añosClase C, o intervalo de reporte demasiado cortoClase A, agregación en el edge y reporte por excepción
La actualización OTA dejó dispositivos inaccesiblesFirmware escrito sobre la partición activa, sin validación posteriorParticiones A/B, firma verificada y confirmación explícita tras comprobar funciones

En la parte I2C

SíntomaCausaSolución
i2cdetect no muestra ninguna direcciónFalta masa común entre las placas, o el módulo no está alimentadoVerificar 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á cargadoHabilitar en config.txt y reiniciar; comprobar con i2cdetect -l
Aparece una dirección que es el doble de la esperadaConfusión entre dirección de 7 y de 8 bitsUsar siempre la de 7 bits; 0xD0 en la hoja de datos es 0x68 en el código
Dos dispositivos y solo uno respondeColisión de direcciones, como MPU6050 y DS3231 en 0x68Mover uno con su pin de dirección, o usar un multiplexor TCA9548A
Funcionaba con dos módulos y falla al agregar el cuartoPull-ups de cada módulo en paralelo; resistencia total demasiado bajaQuitar las pull-ups de todos los módulos menos uno, desoldando o cortando el puente
Errores intermitentes que empeoran al mover el cableCapacitancia excesiva por cable largo; tiempo de subida fuera de especificaciónAcortar el cable, bajar a 100 kHz o reducir el valor de la pull-up
{:error, :i2c_nak} en todas las operacionesNadie responde a esa dirección, o el chip está en reposoConfirmar 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 reiniciandoUn periférico quedó a medio ciclo tirando SDA a nivel bajoGenerar hasta nueve pulsos de reloj manuales sobre SCL para liberar la línea, y luego un STOP
Lecturas correctas mezcladas con basuraEstiramiento de reloj no soportado por el controladorBajar 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 VUmbrales lógicos incompatiblesAdaptador de nivel bidireccional para colector abierto, nunca un divisor resistivo
Los valores del acelerómetro son siempre ceroEl chip está en reposo tras el encendidoEscribir 0x00 en PWR_MGMT_1 (0x6B) durante la inicialización
Los valores tienen signo invertido y saltan a 65000El binario se decodificó sin signoUsar 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:

  1. 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.
  2. Escanear. i2cdetect -y 1. Si no aparece nadie, el problema sigue siendo eléctrico y no vale la pena tocar el software.
  3. Leer el registro de identificación. Casi todos los chips tienen uno con un valor fijo conocido. Si WHO_AM_I responde lo que dice la hoja de datos, la comunicación básica está sana y el resto es software.
  4. Bajar la velocidad. Si el escaneo es intermitente, dtparam=i2c_arm_baudrate=100000 descarta problemas de tiempo de subida en un solo paso.
  5. 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

  1. Escaneo y mapa del bus. Conecta al menos dos periféricos I2C distintos y produce el mapa del bus con i2cdetect -y 1 y con Circuits.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.

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

  3. 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 GenServer del MPU6050: verificación de identidad en init/1, configuración explícita de registros y decodificación con pattern matching de binarios. Incluye el manejo de {:error, :i2c_nak}.

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

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

  6. 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_alive configurado.

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

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

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