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:
- It joins the BACnet/IP network as a client device.
- It reads the
presentValueof a configured list of objects (analog inputs, analog values, binary inputs, multi-state values). - It publishes each value as JSON to an MQTT topic over an outbound TLS connection.
- 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
multiStateValuereturns an integer; publish the state text (for exampleoccupied,unoccupied,standby) alongside the number, using the object's state text list. - Treat binary objects as booleans. BACnet returns
activeorinactive; publishtrueorfalseso 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.jsonis 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/statustoofflineif 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.



