BAC0 is a free, open-source Python library (LGPL-3.0) built on BACpypes3 that provides simple commands for discovering BACnet devices, reading and writing object properties, and recording trend histories—all from a Python script or Jupyter notebook. Install it with pip install BAC0, start a connection with BAC0.start(), and you can read a sensor value in three lines of code.
Why Script BACnet with Python
Clicking through a graphical BACnet explorer works for one-off diagnostics, but it falls apart when you need to repeat the same operation across dozens of controllers. Python scripting with BAC0 fills that gap. Common use cases include:
- Bulk commissioning verification — loop through every VAV box on a floor and confirm that zone temperature sensors read within an expected range
- Automated overnight trend captures — poll a set of points every 10 seconds for 8 hours and export the data to CSV for the engineer's morning review
- Regression testing after firmware updates — write a known value to every Analog Output, read it back, and flag any device that does not respond correctly
- Setpoint batch writes — push updated occupied cooling setpoints to 50 Analog Value objects across a building in seconds instead of clicking through each one
- Integration with data analysis tools — BAC0 stores histories as pandas DataFrames, so you can resample, filter, and plot data using standard Python data science libraries
BAC0 abstracts the complexity of BACnet protocol encoding and decoding. You write human-readable strings like '192.168.1.100 analogInput 1 presentValue' and BAC0 handles the APDU construction, segmentation, and response parsing through the BACpypes3 stack underneath.
Installing BAC0
BAC0 requires Python 3.10 or later and depends on BACpypes3 for BACnet/IP message processing. Install from PyPI:
pip install BAC0For full functionality (pandas-based histories, prettier console output, environment variable support), install the recommended optional dependencies:
pip install BAC0 pandas rich python-dotenvVirtual environment recommended: BAC0 and BACpypes3 pull in several sub-dependencies. Create an isolated environment to avoid conflicts with other projects:
python -m venv bacnet-env
source bacnet-env/bin/activate # Linux/macOS
bacnet-env\Scripts\activate # Windows
pip install BAC0 pandasVerify the install by opening a Python asyncio REPL (python -m asyncio) and running import BAC0. If no errors appear, you are ready to connect.
Connecting to the BACnet Network
BAC0 uses Python's asyncio for all network I/O. The primary entry point is BAC0.start(), which initializes a BACnet/IP application, binds to a UDP port (default 47808 / 0xBAC0), and begins listening for I-AM responses.
Recommended: Async Context Manager
The cleanest pattern uses async with, which automatically handles cleanup when the block exits:
import asyncio
import BAC0
async def main():
async with BAC0.start(ip="192.168.1.10/24") as bacnet:
# bacnet is now connected and ready
print("Connected to BACnet network")
# Your reads, writes, and discoveries go here
asyncio.run(main())The ip parameter tells BAC0 which network interface to bind to. Specify the IP address of your machine's adapter on the BACnet subnet, followed by the CIDR mask. If you omit ip, BAC0 attempts auto-detection, but on machines with multiple network interfaces this often picks the wrong adapter—always specify it explicitly.
Interactive Use with asyncio REPL
For exploratory work, start the asyncio REPL so you can run await calls at the top level:
python -m asyncio
>>> import BAC0
>>> bacnet = await BAC0.start(ip="192.168.1.10/24")
>>> # Now use bacnet directly
>>> # When done:
>>> await bacnet.stop()Jupyter notebooks also support top-level await, making them a good fit for interactive BACnet exploration during commissioning.
Foreign Device Registration
If your BACnet devices are on a different subnet and you need to register with a BBMD, pass the BBMD address when starting:
async with BAC0.start(
ip="10.0.0.50/24",
bbmdAddress="192.168.1.1",
bbmdTTL=900
) as bacnet:
# Registered as a foreign device
passDiscovering BACnet Devices
After connecting, use the _discover() method to send WHO-IS broadcasts and collect I-AM responses:
async with BAC0.start(ip="192.168.1.10/24") as bacnet:
# Global broadcast - finds devices on all reachable networks
await bacnet._discover(global_broadcast=True)
# Check discovered devices
print(bacnet.discoveredDevices)bacnet.discoveredDevices returns a dictionary of discovered device addresses and their instance numbers. By default, _discover() uses a local broadcast to minimize network traffic. Use global_broadcast=True when you need to reach devices behind BACnet routers, or use bacnet.discover(networks='known') to search specific network numbers.
Tip: On large networks, discovery can take several seconds. Give it adequate time before checking results. If you are not seeing all devices, verify that your IP and subnet mask are correct and that no firewall is blocking UDP port 47808.
Reading and Writing BACnet Values
Reading a Single Property
The read() method accepts a space-delimited string containing the device address, object type, object instance, and property name:
async with BAC0.start(ip="192.168.1.10/24") as bacnet:
# Read presentValue from analogInput 1 on device at 192.168.1.100
value = await bacnet.read(
"192.168.1.100 analogInput 1 presentValue"
)
print(f"Zone Temp: {value}")
# Read from a device behind a BACnet router (network 303, device 9)
value = await bacnet.read(
"303:9 analogValue 4410 presentValue"
)Reading Multiple Properties
Use readMultiple() with a request dictionary for efficient bulk reads. This sends a single ReadPropertyMultiple request instead of individual ReadProperty calls, reducing network round trips:
request = {
"address": "192.168.1.100",
"objects": {
"analogInput:1": [
"objectName",
"presentValue",
"statusFlags",
"units",
"description"
],
"analogInput:2": [
"objectName",
"presentValue",
"statusFlags",
"units"
],
"analogValue:100": [
"objectName",
"presentValue"
]
}
}
result = await bacnet.readMultiple(
"192.168.1.100",
request_dict=request
)
print(result)Writing a Value
The write() method uses a similar string format, appending the value and an optional priority level separated by a dash:
# Write 72.0 to analogValue 100 presentValue at priority 8
await bacnet._write(
"192.168.1.100 analogValue 100 presentValue 72.0 - 8"
)
# Write without specifying priority (uses device default)
await bacnet._write(
"192.168.1.100 analogValue 100 presentValue 72.0"
)Priority levels matter. BACnet uses a 16-level priority array for commandable objects. Priority 1 is the highest (Manual Life Safety) and priority 16 is the lowest. For commissioning overrides, use priority 8 (Manual Operator). Always relinquish your overrides when finished to avoid locking out the controller's normal programming.
Working with Device Objects
For ongoing interaction with a specific controller, define a device object. This auto-discovers all BACnet objects on the device and lets you access points by name:
async with BAC0.start(ip="192.168.1.10/24") as bacnet:
# Connect to device instance 5504 at network address 3:4
controller = await BAC0.device("3:4", 5504, bacnet)
# List all discovered points
print(controller.points)
# Read a point by name
zone_temp = controller["ZN-T"]
print(f"Zone temperature: {zone_temp}")
# Write a new setpoint
controller["ZN-SP"] = 72.0
# Release an override (return to automatic control)
controller["ZN-SP"] = "auto"The device object polls points automatically at a configurable interval (default 10 seconds) and stores every reading in its history. For older controllers that do not support segmentation, pass segmentation_supported=False to the BAC0.device() call.
Exporting Trend Data
BAC0 automatically logs every polled value into a pandas Series. If pandas is installed, you get access to resampling, statistics, filtering, and export with no additional setup.
Accessing Point Histories
async with BAC0.start(ip="192.168.1.10/24") as bacnet:
controller = await BAC0.device("3:4", 5504, bacnet)
# Let it poll for a while (values recorded every 10 seconds)
await asyncio.sleep(300) # 5 minutes of data
# Access the history as a pandas Series
history = controller["ZN-T"].history
print(history)
print(f"Mean: {history.mean():.1f}")
print(f"Min: {history.min():.1f}")
print(f"Max: {history.max():.1f}")Exporting to CSV
import pandas as pd
# Export a single point
controller["ZN-T"].history.to_csv("zone_temp_log.csv")
# Combine multiple points into a DataFrame and export
df = pd.DataFrame({
"ZN-T": controller["ZN-T"].history,
"ZN-SP": controller["ZN-SP"].history,
"DPR-O": controller["DPR-O"].history
})
df.to_csv("vav_trend_data.csv")
# Resample to 1-minute intervals before export
df_1min = df.resample("1min").mean().dropna()
df_1min.to_csv("vav_trend_1min.csv")Exporting to Excel
# Requires openpyxl: pip install openpyxl
df.to_excel("vav_trend_data.xlsx", sheet_name="VAV-101")Controlling History Size
By default, BAC0 stores unlimited history in memory. On long-running scripts, this can consume significant RAM. Limit the history buffer when defining the device or per-point:
# Limit to 500 readings at device level
controller = await BAC0.device(
"3:4", 5504, bacnet, history_size=500
)
# Or adjust after creation
controller.update_history_size(1000)
# Or per individual point
controller["ZN-T"].properties.history_size = 200Common BAC0 Python Mistakes
- Forgetting
awaiton async calls. BAC0 is built on asyncio. Callingbacnet.read(...)withoutawaitreturns a coroutine object instead of the actual value. If you see<coroutine object ...>in your output, you forgot toawaitthe call. - Not specifying the IP/subnet mask. If you omit the
ipparameter inBAC0.start(), BAC0 guesses which network interface to bind to. On a laptop with Wi-Fi, Ethernet, and VPN adapters, it frequently picks the wrong one. Always passip="your.ip.addr/mask"explicitly. - Writing at the wrong priority and forgetting to relinquish. Writing a value at priority 8 overrides the controller's normal schedule-driven logic. If you walk away without relinquishing, the override persists indefinitely. Always write
controller['pointName'] = 'auto'or send a null write to release the priority slot when done. - Running out of memory on long trend captures. BAC0 stores unlimited history by default. A script polling 200 points every 10 seconds for 24 hours generates over 1.7 million readings. Set
history_sizeto cap the buffer, or periodically export and clear data. - Using Python 3.9 or earlier. BAC0 depends on BACpypes3, which requires Python 3.10+. Attempting to install on Python 3.8 or 3.9 will either fail during install or produce cryptic import errors at runtime. Check your version with
python --versionbefore installing.
Platform and Version Compatibility
| Component | Requirement | Notes |
|---|---|---|
| Python | 3.10+ | Required by BACpypes3; 3.11 or 3.12 recommended |
| BAC0 | Latest PyPI release | Current version as of this writing is v2025.09.15 |
| BACpypes3 | Installed automatically | Core BACnet/IP protocol stack; handles APDU encoding |
| pandas | Optional but recommended | Required for histories, CSV/Excel export, and resampling |
| Windows | Full support | Most common platform for BAS technicians |
| Linux | Full support | Works well on field laptops and Raspberry Pi |
| macOS | Full support | Supported through standard Python; no special setup needed |
BACnet transport: BAC0 supports BACnet/IP only. It does not directly communicate over BACnet MS/TP (RS-485). To reach MS/TP devices, you need a BACnet router between the IP and MS/TP networks—BAC0 communicates with the router over IP, and the router relays to the MS/TP trunk. This is the same architecture most BAS front-ends use.
Firewall and port considerations: BAC0 binds to UDP port 47808 by default. If another BACnet application is already using this port on the same machine, BAC0 will fail to start. Close other BACnet tools before launching your script, or configure BAC0 to use an alternate port.
Source Attribution
This tutorial draws from the following sources:
- BAC0 GitHub Repository — source code, release notes, and issue tracker (LGPL-3.0 license, ~230 stars)
- BAC0 ReadTheDocs — official documentation covering connection, reads, writes, histories, trending, and device definitions
- BACpypes3 Documentation — underlying BACnet protocol stack documentation
BAC0 is maintained by Christian Tremblay and licensed under the GNU Lesser General Public License v3.0 (LGPL-3.0). You may use it freely in commercial and proprietary projects as long as modifications to BAC0 itself remain open-source per the LGPL terms.
Was this article helpful?
Related Articles
Need to do this remotely? SiteConduit provides Layer 2 access that preserves BACnet broadcasts — no BBMD needed for remote sessions. Join the waitlist.
SiteConduit Technical Team
Idea Networks Inc.
SiteConduit builds managed remote access for building automation. Our knowledge base is maintained by BAS professionals with hands-on experience deploying and troubleshooting BACnet, Niagara, Modbus, and Facility Explorer systems.