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.
| Criteria | BAC0 | BACpypes3 |
|---|---|---|
| Primary role | High-level scripting and automation | Full BACnet protocol stack |
| Best for | Reads, writes, trend logging, commissioning scripts | Custom devices, gateways, simulators, proprietary services |
| Learning curve | Low—string-based API, minimal BACnet knowledge needed | Moderate—requires understanding of BACnet services and APDUs |
| Custom BACnet objects | Limited (pass-through to BACpypes3) | Full support—define, extend, and register any object type |
| COV subscriptions | Supported via device objects | Full control over subscription lifecycle |
| Device simulation | Not designed for this | Core use case—present objects that other BACnet clients can discover and read |
| Dependency | Requires 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 bacpypes3For 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
- Forgetting
awaiton coroutines. Every BACpypes3 network operation (who_is(),read_property(),write_property()) is anasynccoroutine. Calling one withoutawaitreturns a coroutine object instead of the actual result. If your code prints<coroutine object ...>, add the missingawait. - 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 useon Linux orWinError 10048on Windows. Close the other application or configure a different port. - 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.
- 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
ifaddrorip addr showto confirm adapter addresses before configuring your application. - Confusing BACpypes (v1) imports with BACpypes3 imports. The original
bacpypespackage (Python 2/3, synchronous) andbacpypes3(Python 3, async) use different module paths and class names. Importing frombacpypes.appinstead ofbacpypes3.appwill either fail with an import error or silently use the wrong, incompatible library. Always verify your imports start withbacpypes3.
Platform Compatibility
| Component | Requirement | Notes |
|---|---|---|
| Python | 3.8+ | 3.11 or 3.12 recommended for best asyncio performance |
| BACpypes3 | Latest PyPI release | Current version 0.0.106 (March 2026) |
| ifaddr | Optional | Automatic network interface detection; install via bacpypes3[full] |
| pyyaml | Optional | Load application configuration from YAML files |
| Windows | Full support | Ensure Windows Firewall allows UDP 47808 inbound |
| Linux | Full support | Works on x86 and ARM (Raspberry Pi); no root required unless binding to port below 1024 |
| macOS | Full support | Standard 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 GitHub Repository — source code, sample applications, and issue tracker maintained by Joel Bender (MIT license, ~87 stars)
- BACpypes3 ReadTheDocs — official documentation covering setup, UDP communications, application architecture, and sample walkthroughs
- bacpypes3 on PyPI — package index listing with version history and dependency information
- BAC0 GitHub Repository — higher-level library built on BACpypes3, useful for understanding the abstraction boundary between the two
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.
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.