BACnet (Building Automation and Control Networks, standard ASHRAE 135 and ISO 16484-5) is the protocol that building management systems use to read and command HVAC, lighting and energy equipment. On a Raspberry PLC you use BACnet/IP with BAC0, a Python library based on BACpypes3: install it in a virtual environment, open the connection with async with BAC0.start(ip="IP/mask"), find devices with who_is(), read with read() and command values with a write at a priority. The PLC can also publish its own values as BACnet objects, so that the building management system reads it like any other controller.
This guide explains the BACnet concepts you need, how to install the current BAC0 on a Raspberry PLC, and two scripts: a client that reads and writes another BACnet device, and a server that turns the Raspberry PLC into a BACnet device. Both scripts were tested on 27/10/2026 with BAC0 2026.7.25 and BACpypes3 0.0.110, between two BAC0 instances on a Linux PC.
- Step 1NetworkPLC and BACnet devices on the same IP subnet, UDP 47808
- Step 2Install BAC0Python 3.10 or later, in a virtual environment
- Step 3Read and writewho_is(), read(), readMultiple() and write at a priority
- Step 4Serve objectsThe PLC as a BACnet device for the BMS
BACnet in five concepts
Every BACnet device is seen the same way, whatever its manufacturer. A building management system (BMS) does not read registers, as in Modbus: it reads named objects with typed properties.
| Concept | What it is | Example |
|---|---|---|
| Device | Each controller on the network. It has a device instance that must be unique in the whole BACnet network (0 to 4194302) | device 1001 |
| Object | A point of the device, with a type and an instance number | analogInput 1, analogValue 1, binaryOutput 3 |
| Property | A data field of an object | presentValue, objectName, units, statusFlags |
| Service | The request a device sends to another one | Who-Is and I-Am to discover, ReadProperty, ReadPropertyMultiple, WriteProperty, COV subscriptions |
| Priority array | 16 levels in commandable objects. The value at the highest priority wins. Writing null releases a level | Priority 8 is manual operator. Priority 16 is the lowest |
BACnet/IP and BACnet MS/TP
BACnet runs on several networks. The two usual ones in buildings are BACnet/IP, over Ethernet or Wi-Fi on UDP port 47808, and BACnet MS/TP, over RS-485. BAC0 works only with BACnet/IP. To reach MS/TP devices from a Raspberry PLC, put a BACnet router between the IP network and the MS/TP bus: the PLC then reads them with an address such as 2:5 (network 2, MAC address 5).
What you need
- A Raspberry PLC connected by Ethernet to the network of the building, or directly to the PC for a test.
- Another BACnet/IP device to read: a controller, a second Raspberry PLC running the server script below, or a BACnet explorer on your PC.
- All of them on the same IP subnet, with UDP port 47808 open. Who-Is is a broadcast, and broadcasts do not cross IP routers.
- Python 3.10 or later on the PLC. Check it with
python3 --version.
Install BAC0 on the Raspberry PLC
Recent versions of Raspberry Pi OS do not let pip install packages in the system Python, so create a virtual environment and install BAC0 inside it. pip installs BACpypes3 with it.
python3 -m venv ~/bacnet
~/bacnet/bin/pip install BAC0
~/bacnet/bin/python -c "import BAC0; print(BAC0.version)"
Run your scripts with the Python of the environment: ~/bacnet/bin/python bacnet_client.py. Find the IP address and the mask of the PLC with ip a: for 192.168.1.20 with mask 255.255.255.0, write 192.168.1.20/24.
BAC0 changed in 2024. Since the 2024 versions, BAC0 is asynchronous and based on BACpypes3. Scripts written for older versions, such as bacnet = BAC0.lite(ip=...) followed by bacnet.whois() without await, do not work with the current version. The examples on this page use the current syntax. BAC0.start(), BAC0.lite() and BAC0.connect() are the same function.
Example 1: read and write a BACnet device
The script discovers the devices on the network, reads one property, reads three properties in one request and commands a binary value for 5 seconds. Change PLC_IP and DEVICE for your network.
import asyncio
import BAC0
PLC_IP = "192.168.1.20/24" # IP address and mask of this Raspberry PLC
DEVICE = "192.168.1.50" # IP address of the BACnet device to read
async def main():
async with BAC0.start(ip=PLC_IP, deviceId=1002) as bacnet:
# 1. Find the BACnet devices on the network (Who-Is / I-Am)
devices = await bacnet.who_is()
for iam in devices:
print("Found", iam.iAmDeviceIdentifier, "at", iam.pduSource)
# 2. Read one property (ReadProperty)
value = await bacnet.read(f"{DEVICE} analogValue 1 presentValue")
print("AV1 presentValue:", value)
# 3. Read several properties in one request (ReadPropertyMultiple)
print(await bacnet.readMultiple(
f"{DEVICE} analogValue 1 objectName presentValue units"))
# 4. Command a value at priority 8, then release it (WriteProperty)
await bacnet._write(f"{DEVICE} binaryValue 1 presentValue active - 8")
await asyncio.sleep(5)
await bacnet._write(f"{DEVICE} binaryValue 1 presentValue null - 8")
asyncio.run(main())
How it works:
BAC0.start()opens BACnet/IP on port 47808 of the IP address you give.deviceIdis the device instance of the PLC: choose one that no other device uses.who_is()sends a broadcast Who-Is and returns the I-Am answers. Give it an address,who_is(address="192.168.1.50"), to ask a single device.read()takes a text with the address, the object type, the instance and the property. The address can be an IP address, an IP address with a port, ornetwork:MACfor a device behind a BACnet router.readMultiple()reads several properties of an object in one ReadPropertyMultiple request: less traffic when you poll many points.- The write text ends with
- 8: the value goes to priority 8 of the priority array. Writingnullat the same priority releases it, and the object returns to the value of the next priority or to its relinquish default.
In the current BAC0, the write that you can wait for with await is _write(). write() starts the same write as a background task and does not return the result.
BACnet sends analog values as REAL, a 32-bit floating point number. A value of 22.7 can arrive as 22.700000762939453: round it before you show it or store it.
Which priority to write
| Priority | Use in the standard |
|---|---|
| 1 | Manual life safety |
| 2 | Automatic life safety |
| 5 | Critical equipment control |
| 6 | Minimum on and off times |
| 8 | Manual operator |
| 3, 4, 7 and 9 to 16 | Free for the application. 16 is the lowest |
Agree on the priority with the integrator of the building. A value written at a high priority and never released keeps the equipment in that state, whatever the BMS commands at a lower priority.
Example 2: the Raspberry PLC as a BACnet device
A BMS often needs to read values that the PLC measures or calculates. With BAC0, the PLC can serve its own objects: here an analog value with a room temperature and a commandable binary value that the BMS writes to start a fan.
import asyncio
import random
import BAC0
from BAC0.core.devices.local.factory import analog_value, binary_value
PLC_IP = "192.168.1.20/24" # IP address and mask of this Raspberry PLC
def read_temperature():
# Replace with the reading of your sensor (analog input, Modbus...)
return round(random.uniform(20.0, 24.0), 1)
def set_fan(on):
# Replace with the code that switches your output
print("Fan", "ON" if on else "OFF")
async def main():
async with BAC0.start(ip=PLC_IP, deviceId=1001,
localObjName="RaspberryPLC-1") as bacnet:
analog_value(instance=1, name="Room_Temperature", presentValue=21.5,
properties={"units": "degreesCelsius"},
description="Room temperature")
objects = binary_value(instance=1, name="Fan_Command",
presentValue=False, is_commandable=True)
objects.add_objects_to_application(bacnet)
app = bacnet.this_application.app
temperature = app.get_object_name("Room_Temperature")
fan = app.get_object_name("Fan_Command")
while True:
temperature.presentValue = read_temperature()
set_fan(str(fan.presentValue) == "active")
await asyncio.sleep(1)
asyncio.run(main())
How it works:
analog_value()andbinary_value()define the objects, with their name, instance, initial value and units.is_commandable=Truegives the object a priority array, so other devices can write it at a priority.add_objects_to_application()publishes the objects. The BMS finds the PLC with Who-Is as device 1001 and reads its object list.- The loop updates the temperature every second and applies the fan command to the output. Replace
read_temperature()andset_fan()with the reading of your input and the control of your output.
Troubleshooting
| What you see | Likely cause | What to do |
|---|---|---|
InitializationError: IP Address provided ... invalid | Another program uses UDP port 47808, or the IP address is not on the PLC | Stop the other BACnet program or use another port with ip="192.168.1.20/24:47809". Check the address with ip a |
who_is() returns an empty list | The devices are on another IP subnet, or a firewall blocks UDP 47808 | Ask the device by its address. Across subnets, BACnet/IP needs a BBMD: BAC0 registers with one through the bbmdAddress and bbmdTTL parameters |
NoResponseFromController | The device does not answer the read | Check the address, the network number if there is a router, and the device instance |
The log shows unknown-object, or the read raises UnknownPropertyError | The object or the property does not exist in that device | Read the object list of the device first: read("192.168.1.50 device 1234 objectList") |
| The write has no effect | A higher priority holds the value, or the object is not commandable | Read the priorityArray property of the object and ask which level the BMS uses |
BACnet or Modbus TCP in a building
Many HVAC units and meters speak Modbus, while the BMS of the building asks for BACnet. There are two common designs. The PLC serves a fixed Modbus TCP map and a gateway next to it translates the map to BACnet, as in the air handling unit case below. Or the Raspberry PLC reads the Modbus devices and publishes the values as BACnet objects itself, with the server script of this page. For Modbus on the Raspberry PLC, see the Modbus TCP and RTU guide for Raspberry PLC.
Take it further
BACnet lives in buildings. See a Raspberry PLC run the HVAC of a shopping centre, a simulator of building zones and a unit controller that talks to the BMS.
Success story
One PLC for the mall HVAC. Watched from afar.
One Raspberry PLC runs, logs and reports the HVAC unit of a 5,000 m² shopping centre to the SCADA.
Read the success story
Simulator
Run the zones of a building: people, light, climate and alarms
One Raspberry PLC 57R runs four zones of an office floor. Change the rules, open a window and watch the energy, the comfort and the alerts.
Open the simulator
Case study
One controller design. Every unit in the series.
An M-Duino 58 runs an air handling unit and gives the BMS a Modbus TCP map. A gateway adds BACnet when the building asks for it.
Read the case study
FAQ about BACnet on the Raspberry PLC
Which port does BACnet/IP use?
UDP port 47808 (0xBAC0 in hexadecimal, which gives its name to BAC0). Open it in the firewall of the PLC and of the network.
Can the Raspberry PLC talk BACnet MS/TP over RS-485?
Not with BAC0, which only supports BACnet/IP. Use a BACnet router between the IP network and the MS/TP bus, and read the MS/TP devices through it.
Why does my old BAC0 script stop working?
Since the 2024 versions, BAC0 is asynchronous. Start it with async with BAC0.start(...) inside an async function and wait for each call with await. The method whois() is now who_is().
Can a building management system read the Raspberry PLC?
Yes. With the server script of this page, the PLC answers Who-Is and serves its objects. The BMS reads them and writes the commandable ones like those of any other BACnet controller.
Is BACnet free to use?
Yes. BACnet is an open standard of ASHRAE, also published as ISO 16484-5. You can use it without a licence. BAC0 is open source, under the LGPLv3 licence.
More protocols on the Raspberry PLC
- Modbus TCP and RTU guide for Raspberry Pi PLCs with Python and Node-RED
- Modbus RTU vs Modbus TCP vs OPC UA: which to use
- Choosing an industrial controller
References: BAC0 documentation and BAC0 on PyPI.