IoT Automation

BACnet to MQTT: Integrating HVAC Controllers with a Python Gateway

Read analog and binary objects from BACnet/IP HVAC controllers with BAC0 and publish them to an MQTT broker as clean JSON, with deadbands, retained metadata and safe write-back.

HVAC controller on a BACnet/IP network connected through a small gateway to a cloud broker in a bacnet to mqtt integration
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

A BACnet to MQTT gateway reads points from HVAC controllers on the local BACnet/IP network and republishes them as MQTT messages that dashboards and analytics tools can subscribe to from anywhere. In this tutorial you will build that gateway in Python with BAC0 and paho-mqtt, test it against the public broker, and then point it at a private ConnectMQ broker over TLS.

How the Gateway Works

BACnet/IP devices speak UDP on port 47808 and expect to be on the same broadcast domain as the client. That is why you do not connect a cloud service to BACnet directly. Instead, a small gateway sits on the building network:

  1. It joins the BACnet/IP network as a client device.
  2. It reads the presentValue of a configured list of objects (analog inputs, analog values, binary inputs, multi-state values).
  3. It publishes each value as JSON to an MQTT topic over an outbound TLS connection.
  4. Optionally, it subscribes to a small set of command topics and writes approved setpoints back to the controller.

Because the MQTT connection is outbound only, you do not open any inbound firewall ports. This is the same pattern described in our overview of building automation system MQTT architectures.

What You Need

  • A Linux machine, Raspberry Pi or Windows PC on the same subnet as the BACnet/IP controllers.
  • Python 3.10 or newer.
  • The device instance and IP address of at least one controller, plus the object types and instances you want. Your BMS front-end or a BACnet explorer tool will show these.
  • An MQTT broker. For the first test use the free public MQTT broker at broker.connectmq.com. For real building data, use a private broker.

Install the libraries:

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

Recent BAC0 releases are built on bacpypes3 and use asyncio, which is what the code below targets.

Step 1: Discover Devices and Points

Before writing a gateway, confirm the machine can see the controllers. BAC0 needs to bind to the interface on the BACnet subnet, so pass its address with the subnet mask.

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())

If discovery returns nothing, check that the gateway and controllers are on the same subnet, that UDP 47808 is not blocked by a host firewall, and that no other BACnet application on the same machine already owns the port. If the controller lives behind a BACnet router on another subnet, you may need a BBMD or foreign device registration; ask whoever maintains the BMS.

Step 2: Describe the Points You Want

Keep the point list in a configuration file rather than hard-coding it. Each entry maps a BACnet object to an MQTT topic suffix, a unit and a deadband.

{
  "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}
  ]
}

Save it as points.json.

Step 3: The BACnet to MQTT Gateway Script

The script polls each point, publishes only when the value changes by more than its deadband (or every five minutes as a heartbeat), and handles write commands for points marked 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                      # use 8883 with TLS for a private broker
USERNAME = None                  # "YOUR_USERNAME" for a private broker
PASSWORD = None                  # "YOUR_PASSWORD"
CLIENT_ID = f"bacnet-gw-{uuid.uuid4().hex[:8]}"   # "YOUR_CLIENT_ID" for a private broker
PREFIX = "connectmq/demo/bacnet-7k2q"             # unique prefix on the public broker
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 -> (value, timestamp)
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 runs callbacks in its own thread: hand the job to the asyncio loop safely
    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, unknown object, device offline
            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"   # priority 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())

Run it with python gateway.py. Each point appears under connectmq/demo/bacnet-7k2q/ist01/....

Mapping BACnet Objects to MQTT Topics

BACnet names objects by type and instance number, for example analogInput 3 on device 1201. Those identifiers mean nothing to a facility manager browsing topics, and they change if a controller is replaced or reprogrammed. That is why the point list maps every object to a descriptive topic such as ahu-1/supply-air-temp, while the original reference travels inside the payload as source.

A few conventions keep the mapping maintainable as the building grows:

  • Name equipment, not controllers. One controller may serve two air handlers, and one air handler may span two controllers. Topics should follow the equipment.
  • Use one unit system. Convert Fahrenheit or inches of water column in the gateway if a site mixes vendors, and put the unit in every payload.
  • Translate multi-state values. A multiStateValue returns an integer; publish the state text (for example occupied, unoccupied, standby) alongside the number, using the object's state text list.
  • Treat binary objects as booleans. BACnet returns active or inactive; publish true or false so dashboards and alert rules can compare them easily.
  • Keep the list in version control. When a mechanical contractor renumbers objects, a reviewed change to points.json is far easier to audit than a hand-edited script.

Step 4: Verify the Data

Subscribe from any machine:

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

Then send a test setpoint change to the writable VAV point:

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}'

The gateway writes the value at priority 8 and publishes an acknowledgement on the /ack topic. Remember that everything on the public broker is readable by anyone, so do this only with a test controller or a simulator. A command topic on a public broker should never reach a real building.

Step 5: Move to a Private Broker over TLS

For production, create a broker in the ConnectMQ portal and copy the generated username, password and client ID. Change four constants:

BROKER = "broker.connectmq.com"   # or the host shown for your broker in the portal
PORT = 8883
USERNAME = "YOUR_USERNAME"
PASSWORD = "YOUR_PASSWORD"
CLIENT_ID = "YOUR_CLIENT_ID"

client.tls_set() uses the system CA store, which already trusts the Let's Encrypt certificate the broker presents. Use a topic root such as acme/ist01/... instead of the demo prefix. A hosted MQTT broker keeps this gateway simple: no certificates to renew on your side and no broker to patch.

Troubleshooting Checklist

Symptom Likely cause Fix
Discovery finds no devices Wrong interface or subnet mask Pass the gateway IP with the correct /24 (or your mask) to BAC0.start
Address already in use Another BACnet tool bound to 47808 Stop the other tool or run the gateway on another host
Reads time out on one device Device is behind a BACnet router Configure foreign device registration with the site BBMD
Values never change on MQTT Deadband too large Lower the deadband or check the heartbeat interval
Writes accepted but value reverts A higher priority (schedule, operator) owns the point Agree on a priority with the BMS integrator; relinquish when done
MQTT reconnect loop with private broker Client ID does not match credentials Use the exact client ID issued with the username

Production Tips

  • Prefer COV where supported. BACnet change-of-value subscriptions reduce polling load on old controllers. Start with polling for simplicity, then move busy points to COV.
  • Do not poll too aggressively. Older MS/TP controllers behind routers respond slowly; 15 to 60 seconds is usually plenty for HVAC.
  • Publish metadata as retained. A retained message with units, object names and min/max helps dashboards render correctly from the first second.
  • Watch the gateway itself. The last-will message flips gateway/status to offline if the process dies, and a ConnectMQ threshold alert rule on key telemetry can flag sensors that go out of range.

For a hands-on use case built on these points, see HVAC monitoring with IoT sensors. If the same building also uses KNX for lighting, the KNX to MQTT bridge follows the same structure. More automation projects are collected in our IoT automation guide, and the official BAC0 documentation lives at bac0.readthedocs.io.

Bring Your BACnet Data to ConnectMQ

Sign up at app.connectmq.com and create a free Serverless broker, no credit card required. Point the gateway at it, open the live topic explorer and you will see every HVAC point arrive in real time. ConnectMQ handles TLS, authentication and uptime so your team can focus on the building.

Frequently Asked Questions

Can a cloud service talk BACnet directly?

Not reliably. BACnet/IP depends on UDP broadcasts on a local network, so you need a gateway on site that converts BACnet points to MQTT and connects outbound to the broker.

Which BACnet objects should I publish first?

Start with supply and return air temperatures, filter differential pressure, fan status and main zone temperatures. They give the most diagnostic value for the fewest points.

Is it safe to write setpoints over MQTT?

It can be, if the gateway only allows specific points, validates ranges, writes at an agreed priority and uses an authenticated TLS connection to a private broker.

What polling interval should I use?

For HVAC, 15 to 60 seconds with a deadband is typical. Faster polling rarely adds insight and can overload older controllers.

Related articles

Zigbee2MQTT Setup Guide: From Coordinator to Dashboard

Install Zigbee2MQTT with Docker, configure the coordinator and MQTT connection, pair and rename devices, control them over MQTT and view their states on a dashboard, testing first on a free public broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.