Python BACnet Scripting with BAC0

Scripts & AutomationBAC0PythonBACnet scriptingautomation
April 5, 2026|10 min read

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:

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 BAC0

For full functionality (pandas-based histories, prettier console output, environment variable support), install the recommended optional dependencies:

pip install BAC0 pandas rich python-dotenv

Virtual 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 pandas

Verify 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
    pass

Discovering 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 = 200

Common BAC0 Python Mistakes

  1. Forgetting await on async calls. BAC0 is built on asyncio. Calling bacnet.read(...) without await returns a coroutine object instead of the actual value. If you see <coroutine object ...> in your output, you forgot to await the call.
  2. Not specifying the IP/subnet mask. If you omit the ip parameter in BAC0.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 pass ip="your.ip.addr/mask" explicitly.
  3. 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.
  4. 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_size to cap the buffer, or periodically export and clear data.
  5. 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 --version before installing.

Platform and Version Compatibility

ComponentRequirementNotes
Python3.10+Required by BACpypes3; 3.11 or 3.12 recommended
BAC0Latest PyPI releaseCurrent version as of this writing is v2025.09.15
BACpypes3Installed automaticallyCore BACnet/IP protocol stack; handles APDU encoding
pandasOptional but recommendedRequired for histories, CSV/Excel export, and resampling
WindowsFull supportMost common platform for BAS technicians
LinuxFull supportWorks well on field laptops and Raspberry Pi
macOSFull supportSupported 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 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.

BAC0PythonBACnet scriptingautomationtrending

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.

SC

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.