Automatización IoT

BACnet a MQTT: integración de controladores de climatización con un gateway en Python

Lee objetos analógicos y binarios de controladores de climatización BACnet/IP con BAC0 y publícalos en un broker MQTT como JSON limpio, con bandas muertas, metadatos retenidos y escritura de vuelta segura.

Controlador de climatización en una red BACnet/IP conectado a través de un pequeño gateway a un broker en la nube en una integración de BACnet a MQTT
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Un gateway de BACnet a MQTT lee puntos de los controladores de climatización en la red BACnet/IP local y los vuelve a publicar como mensajes MQTT a los que los paneles y las herramientas de analítica pueden suscribirse desde cualquier lugar. En este tutorial construirás ese gateway en Python con BAC0 y paho-mqtt, lo probarás contra el servidor público gratuito de ConnectMQ y después lo apuntarás a un broker privado de ConnectMQ mediante TLS.

Cómo funciona el gateway

Los dispositivos BACnet/IP hablan UDP en el puerto 47808 y esperan estar en el mismo dominio de broadcast que el cliente. Por eso no conectas un servicio en la nube directamente a BACnet. En su lugar, un pequeño gateway se instala en la red del edificio:

  1. Se une a la red BACnet/IP como dispositivo cliente.
  2. Lee el presentValue de una lista configurada de objetos (entradas analógicas, valores analógicos, entradas binarias, valores multiestado).
  3. Publica cada valor como JSON en un topic MQTT a través de una conexión TLS saliente.
  4. Opcionalmente, se suscribe a un pequeño conjunto de topics de órdenes y escribe de vuelta en el controlador las consignas aprobadas.

Como la conexión MQTT es solo saliente, no abres ningún puerto entrante en el cortafuegos. Es el mismo patrón descrito en nuestra visión general de las arquitecturas MQTT para sistemas de automatización de edificios.

Qué necesitas

  • Una máquina Linux, una Raspberry Pi o un PC con Windows en la misma subred que los controladores BACnet/IP.
  • Python 3.10 o posterior.
  • La instancia de dispositivo y la dirección IP de al menos un controlador, además de los tipos e instancias de objeto que quieras. Tu front-end del BMS o una herramienta exploradora de BACnet te los mostrará.
  • Un broker MQTT. Para la primera prueba usa el servidor MQTT público gratuito en broker.connectmq.com, pensado para pruebas y prototipos. Para datos reales del edificio, usa un broker privado.

Instala las librerías:

python -m venv bacnet-gw
source bacnet-gw/bin/activate
pip install "BAC0>=2025.6" "paho-mqtt>=2.0"

Las versiones recientes de BAC0 se basan en bacpypes3 y usan asyncio, que es lo que asume el código siguiente.

Paso 1: descubrir dispositivos y puntos

Antes de escribir un gateway, confirma que la máquina ve los controladores. BAC0 necesita enlazarse a la interfaz de la subred BACnet, así que pásale su dirección con la máscara de subred.

import asyncio
import BAC0

async def main():
    async with BAC0.start(ip="192.168.10.50/24") as bacnet:
        devices = await bacnet.who_is()
        for dev in devices:
            print(dev)
        value = await bacnet.read("192.168.10.21 analogInput 1 presentValue")
        print("AI1 =", value)

asyncio.run(main())

Si el descubrimiento no devuelve nada, comprueba que el gateway y los controladores están en la misma subred, que un cortafuegos local no bloquea UDP 47808 y que ninguna otra aplicación BACnet de la misma máquina ocupa ya el puerto. Si el controlador está detrás de un router BACnet en otra subred, puede que necesites un BBMD o el registro como dispositivo externo (foreign device); pregunta a quien mantenga el BMS.

Paso 2: describir los puntos que quieres

Mantén la lista de puntos en un archivo de configuración en lugar de codificarla en el script. Cada entrada asocia un objeto BACnet con un sufijo de topic MQTT, una unidad y una banda muerta.

{
  "site": "ist01",
  "points": [
    {"addr": "192.168.10.21", "obj": "analogInput 1", "topic": "ahu-1/supply-air-temp", "unit": "degC", "deadband": 0.2},
    {"addr": "192.168.10.21", "obj": "analogInput 2", "topic": "ahu-1/return-air-temp", "unit": "degC", "deadband": 0.2},
    {"addr": "192.168.10.21", "obj": "analogInput 5", "topic": "ahu-1/filter-dp", "unit": "Pa", "deadband": 5},
    {"addr": "192.168.10.21", "obj": "binaryInput 1", "topic": "ahu-1/fan-status", "unit": "", "deadband": 0},
    {"addr": "192.168.10.22", "obj": "analogValue 3", "topic": "vav-301/zone-setpoint", "unit": "degC", "deadband": 0.1, "writable": true, "min": 18, "max": 26}
  ]
}

Guárdalo como points.json.

Paso 3: el script del gateway de BACnet a MQTT

El script sondea cada punto, publica solo cuando el valor cambia más que su banda muerta (o cada cinco minutos como latido) y gestiona las órdenes de escritura de los puntos marcados como writable.

import asyncio
import json
import time
import uuid
from datetime import datetime, timezone

import BAC0
import paho.mqtt.client as mqtt

BROKER = "broker.connectmq.com"
PORT = 1883                      # usa 8883 con TLS para un broker privado
USERNAME = None                  # "YOUR_USERNAME" para un broker privado
PASSWORD = None                  # "YOUR_PASSWORD"
CLIENT_ID = f"bacnet-gw-{uuid.uuid4().hex[:8]}"   # "YOUR_CLIENT_ID" para un broker privado
PREFIX = "connectmq/demo/bacnet-7k2q"             # prefijo único en el broker público
POLL_SECONDS = 15
HEARTBEAT_SECONDS = 300

with open("points.json", encoding="utf-8") as f:
    CONFIG = json.load(f)

POINTS = {p["topic"]: p for p in CONFIG["points"]}
last_sent = {}              # topic -> (valor, marca de tiempo)
write_queue: asyncio.Queue = asyncio.Queue()
loop: asyncio.AbstractEventLoop | None = None


def now_iso():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def on_connect(client, userdata, flags, reason_code, properties):
    print("MQTT connected:", reason_code)
    client.subscribe(f"{PREFIX}/{CONFIG['site']}/cmd/#", qos=1)
    client.publish(f"{PREFIX}/{CONFIG['site']}/gateway/status", "online", qos=1, retain=True)


def on_message(client, userdata, msg):
    suffix = msg.topic.split("/cmd/", 1)[1]
    point = POINTS.get(suffix)
    if not point or not point.get("writable"):
        print("Rejected write to", suffix)
        return
    try:
        value = float(json.loads(msg.payload)["value"])
    except (ValueError, KeyError, TypeError):
        print("Bad command payload:", msg.payload)
        return
    if not point["min"] <= value <= point["max"]:
        print("Out of range:", value)
        return
    # paho ejecuta los callbacks en su propio hilo: pasa el trabajo al bucle asyncio de forma segura
    loop.call_soon_threadsafe(write_queue.put_nowait, (point, value))


def make_client():
    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
    if USERNAME:
        client.username_pw_set(USERNAME, PASSWORD)
        client.tls_set()
    client.will_set(f"{PREFIX}/{CONFIG['site']}/gateway/status", "offline", qos=1, retain=True)
    client.on_connect = on_connect
    client.on_message = on_message
    client.reconnect_delay_set(min_delay=1, max_delay=60)
    client.connect(BROKER, PORT, keepalive=60)
    client.loop_start()
    return client


def should_publish(topic, value, deadband):
    prev = last_sent.get(topic)
    if prev is None:
        return True
    old_value, ts = prev
    if time.time() - ts > HEARTBEAT_SECONDS:
        return True
    if isinstance(value, (int, float)) and isinstance(old_value, (int, float)):
        return abs(value - old_value) >= deadband
    return value != old_value


async def poll(bacnet, client):
    for topic, p in POINTS.items():
        ref = f"{p['addr']} {p['obj']} presentValue"
        try:
            value = await bacnet.read(ref)
            quality = "good"
        except Exception as exc:          # timeouts, objeto desconocido, dispositivo desconectado
            print("Read failed", ref, exc)
            value, quality = None, "bad"
        if isinstance(value, float):
            value = round(value, 2)
        if quality == "bad" or should_publish(topic, value, p["deadband"]):
            payload = {"ts": now_iso(), "value": value, "unit": p["unit"],
                       "quality": quality, "source": f"{p['addr']}/{p['obj']}"}
            client.publish(f"{PREFIX}/{CONFIG['site']}/{topic}", json.dumps(payload), qos=0)
            if quality == "good":
                last_sent[topic] = (value, time.time())


async def handle_writes(bacnet, client):
    while True:
        point, value = await write_queue.get()
        ref = f"{point['addr']} {point['obj']} presentValue {value} - 8"   # prioridad 8
        try:
            await bacnet.write(ref)
            print("Wrote", ref)
            client.publish(f"{PREFIX}/{CONFIG['site']}/{point['topic']}/ack",
                           json.dumps({"ts": now_iso(), "value": value, "result": "ok"}), qos=1)
        except Exception as exc:
            client.publish(f"{PREFIX}/{CONFIG['site']}/{point['topic']}/ack",
                           json.dumps({"ts": now_iso(), "value": value, "result": str(exc)}), qos=1)


async def main():
    global loop
    loop = asyncio.get_running_loop()
    client = make_client()
    async with BAC0.start(ip="192.168.10.50/24") as bacnet:
        asyncio.create_task(handle_writes(bacnet, client))
        while True:
            await poll(bacnet, client)
            await asyncio.sleep(POLL_SECONDS)

asyncio.run(main())

Ejecútalo con python gateway.py. Cada punto aparece bajo connectmq/demo/bacnet-7k2q/ist01/....

Cómo asociar objetos BACnet con topics MQTT

BACnet nombra los objetos por tipo y número de instancia, por ejemplo analogInput 3 en el dispositivo 1201. Esos identificadores no significan nada para un responsable de mantenimiento que recorre los topics, y cambian si se sustituye o reprograma un controlador. Por eso la lista de puntos asocia cada objeto con un topic descriptivo como ahu-1/supply-air-temp, mientras que la referencia original viaja dentro del payload como source.

Algunas convenciones mantienen la asociación manejable a medida que crece el edificio:

  • Nombra los equipos, no los controladores. Un controlador puede atender dos climatizadoras, y una climatizadora puede repartirse entre dos controladores. Los topics deben seguir a los equipos.
  • Usa un único sistema de unidades. Convierte grados Fahrenheit o pulgadas de columna de agua en el gateway si una sede mezcla fabricantes, y pon la unidad en cada payload.
  • Traduce los valores multiestado. Un multiStateValue devuelve un entero; publica el texto del estado (por ejemplo occupied, unoccupied, standby) junto al número, usando la lista de textos de estado del objeto.
  • Trata los objetos binarios como booleanos. BACnet devuelve active o inactive; publica true o false para que los paneles y las reglas de alerta puedan compararlos fácilmente.
  • Guarda la lista en control de versiones. Cuando un instalador mecánico renumera objetos, un cambio revisado en points.json es mucho más fácil de auditar que un script editado a mano.

Paso 4: verificar los datos

Suscríbete desde cualquier máquina:

mosquitto_sub -h broker.connectmq.com -p 1883 -i viewer-7k2q -t "connectmq/demo/bacnet-7k2q/#" -v

Después envía un cambio de consigna de prueba al punto VAV escribible:

mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-7k2q -q 1 \
  -t "connectmq/demo/bacnet-7k2q/ist01/cmd/vav-301/zone-setpoint" -m '{"value": 22.5}'

El gateway escribe el valor con prioridad 8 y publica una confirmación en el topic /ack. Recuerda que todo lo que hay en el servidor público puede leerlo cualquiera, así que haz esto solo con un controlador de pruebas o un simulador. Un topic de órdenes en un servidor público nunca debe llegar a un edificio real.

Paso 5: pasar a un broker privado con TLS

Para producción, crea un broker en el portal de ConnectMQ y copia el usuario, la contraseña y el client ID generados. Cambia cuatro constantes:

BROKER = "broker.connectmq.com"   # o el host que muestra el portal para tu broker
PORT = 8883
USERNAME = "YOUR_USERNAME"
PASSWORD = "YOUR_PASSWORD"
CLIENT_ID = "YOUR_CLIENT_ID"

client.tls_set() usa el almacén de CA del sistema, que ya confía en el certificado de Let's Encrypt que presenta el broker. Usa una raíz de topic como acme/ist01/... en lugar del prefijo de demostración. Un broker MQTT gestionado mantiene sencillo este gateway: no hay certificados que renovar por tu parte ni broker que parchear.

Lista de comprobación para la resolución de problemas

Síntoma Causa probable Solución
El descubrimiento no encuentra dispositivos Interfaz o máscara de subred incorrectas Pasa a BAC0.start la IP del gateway con el /24 correcto (o tu máscara)
Address already in use Otra herramienta BACnet ocupa el 47808 Detén la otra herramienta o ejecuta el gateway en otro equipo
Las lecturas de un dispositivo agotan el tiempo El dispositivo está detrás de un router BACnet Configura el registro como dispositivo externo con el BBMD de la sede
Los valores nunca cambian en MQTT Banda muerta demasiado grande Reduce la banda muerta o revisa el intervalo de latido
Se aceptan las escrituras pero el valor vuelve atrás Una prioridad superior (horario, operador) controla el punto Acuerda una prioridad con el integrador del BMS; libérala al terminar
Bucle de reconexión MQTT con el broker privado El client ID no coincide con las credenciales Usa exactamente el client ID emitido junto con el usuario

Consejos para producción

  • Prefiere COV cuando esté disponible. Las suscripciones de cambio de valor (COV) de BACnet reducen la carga de sondeo en controladores antiguos. Empieza con sondeo por sencillez y después pasa los puntos más activos a COV.
  • No sondees de forma demasiado agresiva. Los controladores MS/TP antiguos detrás de routers responden lentamente; de 15 a 60 segundos suele bastar para climatización.
  • Publica los metadatos como retenidos. Un mensaje retenido con unidades, nombres de objeto y mínimo/máximo ayuda a que los paneles se muestren correctamente desde el primer segundo.
  • Vigila el propio gateway. El mensaje de última voluntad cambia gateway/status a offline si el proceso muere, y una regla de alerta por umbral de ConnectMQ sobre la telemetría clave puede señalar los sensores que salen de rango.

Para un caso de uso práctico basado en estos puntos, consulta monitorización HVAC con sensores IoT. Los mismos topics pueden alimentar un gemelo digital 3D del edificio, en el que cada climatizadora y cada zona muestran su estado en vivo. Si el mismo edificio usa también KNX para la iluminación, el puente de KNX a MQTT sigue la misma estructura. Reunimos más proyectos de automatización en nuestra guía de automatización IoT, y la documentación oficial de BAC0 está en bac0.readthedocs.io.

Lleva tus datos BACnet a ConnectMQ

Regístrate en app.connectmq.com y crea un broker Serverless gratuito, sin tarjeta de crédito. Apunta el gateway a él, abre el explorador de topics en vivo y verás llegar cada punto de climatización en tiempo real. ConnectMQ se encarga de TLS, la autenticación y la disponibilidad para que tu equipo pueda centrarse en el edificio.

Preguntas frecuentes

¿Puede un servicio en la nube hablar BACnet directamente?

No de forma fiable. BACnet/IP depende de broadcasts UDP en una red local, así que necesitas un gateway in situ que convierta los puntos BACnet a MQTT y se conecte hacia fuera con el broker.

¿Qué objetos BACnet debo publicar primero?

Empieza por las temperaturas del aire de impulsión y retorno, la presión diferencial de los filtros, el estado de los ventiladores y las temperaturas de las zonas principales. Aportan el mayor valor de diagnóstico con el menor número de puntos.

¿Es seguro escribir consignas por MQTT?

Puede serlo, si el gateway solo permite puntos concretos, valida los rangos, escribe con una prioridad acordada y usa una conexión TLS autenticada con un broker privado.

¿Qué intervalo de sondeo debo usar?

En climatización, lo habitual son de 15 a 60 segundos con banda muerta. Un sondeo más rápido rara vez aporta información y puede sobrecargar los controladores antiguos.

Artículos relacionados

Guía para configurar Zigbee2MQTT: del coordinador al panel

Instala Zigbee2MQTT con Docker, configura el coordinador y la conexión MQTT, empareja y renombra dispositivos, contrólalos por MQTT y consulta sus estados en un panel, probando primero en un servidor MQTT público gratuito.

8 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.