BACpypes3: Custom BACnet Applications in Python

Scripts & AutomationBACpypes3PythonBACnet protocoladvanced
May 2, 2026|10 min read

BACpypes3 is a free, open-source Python library (MIT license) that provides low-level, async-native access to the full BACnet protocol stack. Where BAC0 simplifies common tasks behind convenient wrappers, BACpypes3 gives you direct control over service requests, custom object definitions, and application behavior—making it the right choice when you need to build a BACnet device simulator, a protocol gateway, or any application that goes beyond simple reads and writes.

BAC0 vs BACpypes3: When to Use Which

BAC0 and BACpypes3 are not competing libraries—BAC0 is built on top of BACpypes3. The decision comes down to how much protocol-level control your project requires.

CriteriaBAC0BACpypes3
Primary roleHigh-level scripting and automationFull BACnet protocol stack
Best forReads, writes, trend logging, commissioning scriptsCustom devices, gateways, simulators, proprietary services
Learning curveLow—string-based API, minimal BACnet knowledge neededModerate—requires understanding of BACnet services and APDUs
Custom BACnet objectsLimited (pass-through to BACpypes3)Full support—define, extend, and register any object type
COV subscriptionsSupported via device objectsFull control over subscription lifecycle
Device simulationNot designed for thisCore use case—present objects that other BACnet clients can discover and read
DependencyRequires BACpypes3, pandas (optional)No required dependencies beyond Python standard library

Rule of thumb: If your task is “read 200 points and export to CSV,” use BAC0. If your task is “build a virtual BACnet device that exposes sensor data from an MQTT broker,” use BACpypes3 directly.

Installing BACpypes3

BACpypes3 requires Python 3.8 or later and has no mandatory external dependencies. Install from PyPI:

pip install bacpypes3

For enhanced functionality, install the optional extras that enable YAML configuration files, network interface detection, and WebSocket transport:

pip install bacpypes3[full]

The [full] extra pulls in ifaddr (interface detection), pyyaml (YAML config parsing), and websockets (BACnet/SC transport). As with any Python project, use a virtual environment to avoid dependency conflicts:

python -m venv bacpypes-env
source bacpypes-env/bin/activate   # Linux/macOS
bacpypes-env\Scripts\activate      # Windows
pip install bacpypes3[full]

Verify the install by running python -c "import bacpypes3; print(bacpypes3.__version__)". If a version string prints without errors, you are ready to build.

Creating a BACnet Application

Every BACpypes3 program starts with an Application instance. The Application class combines a local BACnet device identity, network transport, and service handlers into a single object. Here is the minimal skeleton for a BACnet/IP client application:

import asyncio
from bacpypes3.app import Application

async def main():
    # Create an application with a local device identity
    app = Application.from_args({
        "name": "SiteConduit-Client",
        "instance": 599,
        "network": {
            "type": "ipv4",
            "address": "192.168.1.50/24",
        },
    })

    # The application is now live on the BACnet network.
    # It will respond to Who-Is requests and can send
    # its own service requests to other devices.
    print("Application started. Press Ctrl+C to exit.")

    try:
        await asyncio.Future()  # Run forever
    except asyncio.CancelledError:
        pass

asyncio.run(main())

The from_args factory method accepts a dictionary (or parsed command-line arguments) describing your device. The instance field sets the BACnet device instance number—choose a value that does not conflict with any existing device on the network. The address field binds the UDP socket to your machine's IP on the BACnet subnet, just like BAC0's ip parameter.

Sending WHO-IS and Processing I-AM

Device discovery in BACnet uses the WHO-IS broadcast and I-AM response pattern. BACpypes3 exposes this through the who_is() coroutine on the Application class:

import asyncio
from bacpypes3.app import Application
from bacpypes3.pdu import IPv4Address

async def discover_devices():
    app = Application.from_args({
        "name": "Discovery-Client",
        "instance": 599,
        "network": {
            "type": "ipv4",
            "address": "192.168.1.50/24",
        },
    })

    # Broadcast WHO-IS to all devices (no instance range filter)
    i_ams = await app.who_is()
    for i_am in i_ams:
        dev_id = i_am.iAmDeviceIdentifier
        addr = i_am.pduSource
        print(f"Device {dev_id[1]} at {addr}")

    # WHO-IS with instance range filter (devices 100-500 only)
    filtered = await app.who_is(
        low_limit=100,
        high_limit=500,
    )
    for i_am in filtered:
        print(f"  Found: {i_am.iAmDeviceIdentifier}")

asyncio.run(discover_devices())

The who_is() method sends the WHO-IS request and collects I-AM responses for a brief window (typically 3 seconds). Each response is an IAmRequest object with attributes including iAmDeviceIdentifier (a tuple of object type and instance number), maxAPDULengthAccepted, and segmentationSupported. Use the instance range parameters to narrow discovery on large networks where a global WHO-IS could flood the trunk with hundreds of I-AM replies.

Reading Properties

Once you know a device's address, use read_property() to retrieve individual object properties:

import asyncio
from bacpypes3.app import Application
from bacpypes3.pdu import IPv4Address
from bacpypes3.primitivedata import ObjectIdentifier

async def read_values():
    app = Application.from_args({
        "name": "Reader-Client",
        "instance": 599,
        "network": {
            "type": "ipv4",
            "address": "192.168.1.50/24",
        },
    })

    device_addr = IPv4Address("192.168.1.100")

    # Read presentValue from analogInput 1
    value = await app.read_property(
        address=device_addr,
        objid=ObjectIdentifier("analog-input,1"),
        prop="present-value",
    )
    print(f"Zone Temp: {value}")

    # Read objectName from the device object itself
    name = await app.read_property(
        address=device_addr,
        objid=ObjectIdentifier("device,5504"),
        prop="object-name",
    )
    print(f"Device Name: {name}")

    # Read a property with an array index (e.g., priority-array slot 8)
    priority = await app.read_property(
        address=device_addr,
        objid=ObjectIdentifier("analog-value,100"),
        prop="priority-array",
        array_index=8,
    )
    print(f"Priority 8 value: {priority}")

asyncio.run(read_values())

For bulk reads, use read_property_multiple() to fetch several properties from several objects in a single request. This dramatically reduces round trips compared to individual read_property() calls:

# Read multiple properties from multiple objects in one request
results = await app.read_property_multiple(
    address=device_addr,
    parameter_list=[
        ObjectIdentifier("analog-input,1"),
            ["object-name", "present-value", "units", "status-flags"],
        ObjectIdentifier("analog-input,2"),
            ["object-name", "present-value", "units"],
        ObjectIdentifier("analog-value,100"),
            ["object-name", "present-value"],
    ],
)
for obj_id, props in results.items():
    print(f"{obj_id}: {props}")

Writing Properties

Write operations change property values on remote BACnet devices. The write_property() coroutine sends a WriteProperty request:

import asyncio
from bacpypes3.app import Application
from bacpypes3.pdu import IPv4Address
from bacpypes3.primitivedata import ObjectIdentifier, Real

async def write_setpoint():
    app = Application.from_args({
        "name": "Writer-Client",
        "instance": 599,
        "network": {
            "type": "ipv4",
            "address": "192.168.1.50/24",
        },
    })

    device_addr = IPv4Address("192.168.1.100")

    # Write 72.0 to analogValue 100 presentValue at priority 8
    await app.write_property(
        address=device_addr,
        objid=ObjectIdentifier("analog-value,100"),
        prop="present-value",
        value=Real(72.0),
        priority=8,
    )
    print("Setpoint written at priority 8")

    # Relinquish the override by writing null at the same priority
    from bacpypes3.primitivedata import Null
    await app.write_property(
        address=device_addr,
        objid=ObjectIdentifier("analog-value,100"),
        prop="present-value",
        value=Null(),
        priority=8,
    )
    print("Override relinquished")

asyncio.run(write_setpoint())

Always relinquish overrides. BACnet's 16-level priority array means a write at priority 8 will override the controller's schedule and program logic until you explicitly release it. Sending a Null() at the same priority clears the slot and lets the next-lower priority take effect. Forgetting this step is one of the most common causes of “stuck” setpoints in the field.

Creating Custom Objects

BACpypes3 can also act as a BACnet server—presenting objects that other BACnet clients on the network can discover, read, and write. This is essential for building protocol gateways, virtual devices, and test simulators.

import asyncio
from bacpypes3.app import Application
from bacpypes3.local.analog import AnalogValueObject
from bacpypes3.local.binary import BinaryValueObject
from bacpypes3.basetypes import EngineeringUnits

async def run_virtual_device():
    app = Application.from_args({
        "name": "Virtual-AHU",
        "instance": 9999,
        "network": {
            "type": "ipv4",
            "address": "192.168.1.50/24",
        },
    })

    # Create an Analog Value for supply air temperature
    sat = AnalogValueObject(
        objectIdentifier=("analog-value", 1),
        objectName="SAT",
        presentValue=55.0,
        units=EngineeringUnits("degrees-fahrenheit"),
        description="Supply Air Temperature",
    )
    app.add_object(sat)

    # Create a Binary Value for fan status
    fan = BinaryValueObject(
        objectIdentifier=("binary-value", 1),
        objectName="SF-STATUS",
        presentValue="active",
        description="Supply Fan Status",
    )
    app.add_object(fan)

    print("Virtual AHU device 9999 is online.")
    print("Other BACnet clients can now discover and read these objects.")

    # Simulate changing values over time
    try:
        temp = 55.0
        while True:
            await asyncio.sleep(5)
            temp = 55.0 + (temp % 5)  # cycle temperature
            sat.presentValue = temp
            print(f"SAT updated to {temp}")
    except asyncio.CancelledError:
        pass

asyncio.run(run_virtual_device())

Once add_object() registers an object with the application, it automatically appears in the device's objectList property. Any BACnet client on the network can send a WHO-IS to find device 9999, then issue ReadProperty requests against analog-value,1 or binary-value,1. This pattern is invaluable for testing integration code against a known, repeatable device before connecting to live controllers.

To create entirely custom object types with proprietary properties, subclass an existing object and add properties to its properties list. Register the new type so BACpypes3 includes it in service handling:

from bacpypes3.local.analog import AnalogValueObject
from bacpypes3.object import Property
from bacpypes3.primitivedata import CharacterString

class LabeledAnalogValue(AnalogValueObject):
    """Analog Value with a custom 'location' property."""
    properties = AnalogValueObject.properties + [
        Property("location", CharacterString, optional=True, mutable=True),
    ]

# Use it like a standard object
labeled_sensor = LabeledAnalogValue(
    objectIdentifier=("analog-value", 10),
    objectName="OAT",
    presentValue=72.5,
    location="Rooftop AHU-1",
)

Common Mistakes

  1. Forgetting await on coroutines. Every BACpypes3 network operation (who_is(), read_property(), write_property()) is an async coroutine. Calling one without await returns a coroutine object instead of the actual result. If your code prints <coroutine object ...>, add the missing await.
  2. Port 47808 already in use. Only one application can bind to UDP port 47808 on a given network interface at a time. If another BACnet tool (a BAS front-end, YABE, or a second BACpypes3 script) is already running, your application will fail to start with an OSError: [Errno 98] Address already in use on Linux or WinError 10048 on Windows. Close the other application or configure a different port.
  3. Using a duplicate device instance number. Every BACnet device on a network must have a unique instance number. If your BACpypes3 application uses the same instance as a physical controller, both devices will respond to WHO-IS requests and confuse every client on the network. Before choosing an instance number, run a WHO-IS scan to confirm the number is not already taken.
  4. Binding to the wrong network interface. On machines with multiple network adapters, omitting or misconfiguring the IP address in the application configuration causes BACpypes3 to bind to the wrong interface. Always specify the exact IP and subnet mask of the adapter connected to the BACnet network. Use ifaddr or ip addr show to confirm adapter addresses before configuring your application.
  5. Confusing BACpypes (v1) imports with BACpypes3 imports. The original bacpypes package (Python 2/3, synchronous) and bacpypes3 (Python 3, async) use different module paths and class names. Importing from bacpypes.app instead of bacpypes3.app will either fail with an import error or silently use the wrong, incompatible library. Always verify your imports start with bacpypes3.

Platform Compatibility

ComponentRequirementNotes
Python3.8+3.11 or 3.12 recommended for best asyncio performance
BACpypes3Latest PyPI releaseCurrent version 0.0.106 (March 2026)
ifaddrOptionalAutomatic network interface detection; install via bacpypes3[full]
pyyamlOptionalLoad application configuration from YAML files
WindowsFull supportEnsure Windows Firewall allows UDP 47808 inbound
LinuxFull supportWorks on x86 and ARM (Raspberry Pi); no root required unless binding to port below 1024
macOSFull supportStandard Python install; no special setup needed

BACnet transport: BACpypes3 supports BACnet/IP (UDP) and BACnet/IPv6 natively. It does not communicate directly over BACnet MS/TP (RS-485). To interact with MS/TP devices, route through a BACnet IP-to-MSTP router—BACpypes3 talks to the router over IP, and the router relays messages to the MS/TP trunk. BACpypes3 also includes experimental support for BACnet Secure Connect (BACnet/SC) over WebSocket transport.

Firewall notes: BACpypes3 binds to UDP port 47808 (0xBAC0) by default. If you are testing on a laptop that also runs a BAS front-end or YABE, stop the other tool first or configure BACpypes3 to use a different port. On Linux, use ss -ulnp | grep 47808 to check whether the port is already occupied before launching your script.

Source Attribution

This article draws from the following sources:

BACpypes3 is maintained by Joel Bender and licensed under the MIT License. You may use, modify, and distribute it freely in both open-source and proprietary projects with no copyleft obligations.

BACpypes3PythonBACnet protocoladvancedcustom application

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.