Example Scripts

The examples/ directory contains 26 runnable scripts demonstrating bac-py’s capabilities. Each script is self-contained with asyncio.run(). Client examples use the high-level Client API; server examples use BACnetApplication with DefaultServerHandlers.

Run any example by replacing the address and object identifiers with values from your network:

uv run python examples/read_value.py

All examples follow the same structure:

import asyncio
from bac_py import Client

async def main():
    async with Client(instance_number=999) as client:
        # ... operations ...

asyncio.run(main())

Tip

Most examples include a logging.basicConfig() call that you can uncomment or adjust to see protocol-level traces. Set level=logging.DEBUG to see every request and response. See Debugging and Logging for the full logger hierarchy and filtering options.

Interactive CLI

interactive_cli.py

A menu-driven interactive CLI for testing Client API features against a real BACnet device. Provides a single tool to explore the full API interactively instead of editing and re-running individual example scripts.

# Start with a target address
uv run python examples/interactive_cli.py 192.168.1.100

# Or enter the address interactively
uv run python examples/interactive_cli.py

The menu offers 10 actions covering the core Client API:

  • Read / Write – single property reads and writes with array index and priority support, plus batch operations via ReadPropertyMultiple and WritePropertyMultiple

  • Discovery – Who-Is device discovery, Who-Has object search, and object list enumeration

  • COV – subscribe and unsubscribe with live [COV]-prefixed notifications printed between menu prompts

  • Device Management – time synchronization using the current system clock

Input uses asyncio.run_in_executor() so the event loop stays responsive for COV callbacks while waiting for user input. Active COV subscriptions are automatically cleaned up on exit.

Reading and Writing

read_value.py

Read a single property from a BACnet device. Demonstrates short aliases ("ai,1", "pv"), full names ("analog-input,1", "object-name"), and array element access via the array_index parameter.

value = await client.read("192.168.1.100", "ai,1", "pv")
name = await client.read("192.168.1.100", "analog-input,1", "object-name")
slot = await client.read("192.168.1.100", "av,1", "priority-array", array_index=8)

See String Aliases for the full alias table.

write_value.py

Write property values with automatic type encoding. Floats become Real, ints are encoded based on the target object/property type, and None relinquishes a command priority.

# Float to analog -> Real
await client.write("192.168.1.100", "av,1", "pv", 72.5, priority=8)

# Int to binary -> Enumerated
await client.write("192.168.1.100", "bo,1", "pv", 1, priority=8)

# Relinquish a priority
await client.write("192.168.1.100", "av,1", "pv", None, priority=8)

See the encoding rules table for details.

read_multiple.py

Read multiple properties from multiple objects in a single ReadPropertyMultiple request:

results = await client.read_multiple("192.168.1.100", {
    "ai,1": ["pv", "object-name", "units"],
    "ai,2": ["pv", "object-name"],
    "av,1": ["pv", "priority-array"],
})

write_multiple.py

Write multiple properties to multiple objects in a single WritePropertyMultiple request, then verify with a read-back:

await client.write_multiple("192.168.1.100", {
    "av,1": {"pv": 72.5, "object-name": "Zone Temp SP"},
    "av,2": {"pv": 55.0},
})

# Verify
results = await client.read_multiple("192.168.1.100", {
    "av,1": ["pv", "object-name"],
    "av,2": ["pv"],
})

Discovery

discover_devices.py

Discover all BACnet devices on the network via Who-Is broadcast. Returns DiscoveredDevice objects with address, instance, vendor ID, max APDU length, and segmentation support. Supports instance range filtering.

devices = await client.discover(timeout=3.0)
for dev in devices:
    print(f"  {dev.instance}  {dev.address_str}  vendor={dev.vendor_id}")

# Filter by instance range
devices = await client.discover(low_limit=100, high_limit=200, timeout=3.0)

extended_discovery.py

Enriches standard Who-Is discovery with Annex X profile metadata (Profile_Name, Profile_Location, Tags) via ReadPropertyMultiple:

devices = await client.discover_extended(timeout=3.0, enrich_timeout=5.0)
for dev in devices:
    print(f"  {dev.instance}: profile={dev.profile_name}")
    if dev.tags:
        print(f"    tags: {dev.tags}")

advanced_discovery.py

Demonstrates three advanced discovery techniques beyond basic Who-Is:

  • Who-Has – find devices containing a specific object by name or identifier:

    results = await client.who_has(object_name="Zone Temp", timeout=3.0)
    results = await client.who_has(object_identifier="ai,1", timeout=3.0)
    
  • Unconfigured device discovery – find new devices that have not yet been assigned an instance number (Clause 19.7 Who-Am-I):

    unconfigured = await client.discover_unconfigured(timeout=5.0)
    for dev in unconfigured:
        print(f"  Vendor: {dev.vendor_id}  Serial: {dev.serial_number}")
    
  • Hierarchy traversal – walk Structured View object trees to collect all object identifiers:

    objects = await client.traverse_hierarchy("192.168.1.100", "structured-view,1")
    

router_discovery.py

Discover BACnet routers and the remote networks they can reach, then discover devices on those remote networks:

routers = await client.who_is_router_to_network(timeout=3.0)
for router in routers:
    print(f"  Router at {router.address}: networks={router.networks}")

# Discover devices on a remote network through a router
devices = await client.discover(destination=f"{remote_net}:*", timeout=5.0)

foreign_device.py

Register as a foreign device with a BBMD to communicate across subnets. Demonstrates discovery on the BBMD’s network and reading BDT/FDT tables:

async with Client(
    instance_number=999,
    bbmd_address="192.168.1.1",
    bbmd_ttl=60,
) as client:
    print(f"Status: {client.foreign_device_status}")

    devices = await client.discover(timeout=5.0)

    bdt = await client.read_bdt("192.168.1.1")
    fdt = await client.read_fdt("192.168.1.1")

COV Subscriptions

monitor_cov.py

Subscribe to object-level COV (Change of Value) notifications with a callback. The device sends notifications whenever the object’s default COV properties change:

from bac_py import decode_cov_values

def on_notification(notification, source):
    values = decode_cov_values(notification)
    for name, value in values.items():
        print(f"  {name}: {value}")

await client.subscribe_cov_ex(
    "192.168.1.100", "ai,1",
    process_id=1,
    callback=on_notification,
    confirmed=True,
    lifetime=3600,
)

await asyncio.sleep(60)
await client.unsubscribe_cov_ex("192.168.1.100", "ai,1", process_id=1)

cov_property.py

Subscribe to property-level COV on a specific property with a custom COV increment (notification threshold). Contrast with monitor_cov.py which uses object-level subscriptions:

# Register a process-ID callback for property-level COV
client.app.register_cov_callback(42, on_notification)

await client.subscribe_cov_property(
    "192.168.1.100", "ai,1", "pv",
    process_id=42,
    cov_increment=0.5,   # notify when value changes by >= 0.5
    lifetime=3600,
)

Events and Alarms

alarm_management.py

Comprehensive alarm management: query active alarms, list event-generating objects, check event state details with pagination, and acknowledge alarms:

# Active alarms
summary = await client.get_alarm_summary(addr)

# Enrollment summaries (all event-generating objects)
enrollment = await client.get_enrollment_summary(
    addr, acknowledgment_filter=AcknowledgmentFilter.ALL,
)

# Event information with pagination
event_info = await client.get_event_information(addr)
while event_info.more_events:
    last = event_info.list_of_event_summaries[-1].object_identifier
    event_info = await client.get_event_information(
        addr, last_received_object_identifier=last,
    )

# Acknowledge an alarm
await client.acknowledge_alarm(
    addr,
    acknowledging_process_identifier=1,
    event_object_identifier="ai,1",
    event_state_acknowledged=EventState.OFFNORMAL,
    time_stamp=ts,
    acknowledgment_source="operator",
    time_of_acknowledgment=ts,
)

text_message.py

Send confirmed (reliable) and unconfirmed (fire-and-forget) text messages:

# Confirmed message (waits for acknowledgment)
await client.send_text_message("192.168.1.100", "Maintenance at 2pm")

# Urgent confirmed message
await client.send_text_message(
    "192.168.1.100", "High temperature alarm!",
    message_priority=MessagePriority.URGENT,
)

# Unconfirmed broadcast
await client.send_text_message(
    "192.168.1.255", "System restart in 5 minutes",
    confirmed=False,
)

Device Management

device_control.py

Device communication control, reinitialization, and time synchronization. All methods accept string enum values ("disable", "warmstart") in addition to enum constants:

# Disable communications (with auto-re-enable after 60 seconds)
await client.device_communication_control(
    addr, enable_disable="disable", time_duration=60,
)

# Re-enable
await client.device_communication_control(addr, enable_disable="enable")

# Warm restart
await client.reinitialize_device(addr, reinitialized_state="warmstart")

# Synchronize device clock
now = datetime.datetime.now(tz=datetime.UTC)
await client.time_synchronization(
    addr,
    BACnetDate(now.year, now.month, now.day, now.isoweekday() % 7),
    BACnetTime(now.hour, now.minute, now.second, 0),
)

object_management.py

Object lifecycle management using string-based identifiers – list, create, and delete objects on a remote device:

# List all objects
objects = await client.get_object_list(addr, device_instance=100)

# Create by type (server assigns instance number)
await client.create_object(addr, object_type="av")

# Create with specific instance
await client.create_object(addr, object_identifier="av,100")

# Delete
await client.delete_object(addr, object_identifier="av,100")

backup_restore.py

Back up and restore a device’s configuration files using the Clause 19.1 procedure:

# Download configuration
backup_data = await client.backup(addr, password="admin")
print(f"Downloaded {len(backup_data.configuration_files)} file(s)")

# Upload configuration
await client.restore(addr, backup_data, password="admin")

audit_log.py

Query audit log records with target-based filtering and pagination:

from bac_py.services.audit import AuditQueryByTarget
from bac_py.types.primitives import ObjectIdentifier

result = await client.query_audit_log(
    addr,
    audit_log="audit-log,1",
    query_parameters=AuditQueryByTarget(
        target_device_identifier=ObjectIdentifier(ObjectType.DEVICE, 100),
    ),
    requested_count=50,
)

for record in result.records:
    print(f"  seq={record.sequence_number}")

# Paginate if more records exist
if not result.no_more_items:
    last_seq = result.records[-1].sequence_number
    next_page = await client.query_audit_log(
        addr, audit_log="audit-log,1",
        query_parameters=query,
        start_at_sequence_number=last_seq + 1,
        requested_count=50,
    )

BACnet/IPv6

ipv6_client.py

Discover devices and read properties over BACnet/IPv6 (Annex U) with multicast discovery using the ff02::bac0 multicast group:

from bac_py import Client

async with Client(ipv6=True) as client:
    devices = await client.discover(timeout=5.0)
    for dev in devices:
        print(f"  {dev.instance} at {dev.address_str}")

    if devices:
        value = await client.read(devices[0].address_str, "dev,*", "object-name")
        print(f"Device name: {value}")

See BACnet/IPv6 (Annex U) for IPv6 transport configuration details.

ipv6_server.py

Run a BACnet/IPv6 server with BACnetApplication and DefaultServerHandlers for full APDU dispatch (ReadProperty, WriteProperty, Who-Is, etc.):

from bac_py.app.application import BACnetApplication, DeviceConfig

config = DeviceConfig(instance_number=500, name="IPv6-Server", ipv6=True)
app = BACnetApplication(config)
await app.start()

# Create objects, register DefaultServerHandlers, then wait for requests

BACnet Ethernet

ethernet_server.py

Run a BACnet server over raw IEEE 802.3/802.2 Ethernet frames (Clause 7) using BACnetApplication and DefaultServerHandlers. Requires CAP_NET_RAW or root privileges on Linux, and a BPF device on macOS:

from bac_py.app.application import BACnetApplication, DeviceConfig

config = DeviceConfig(
    instance_number=600,
    name="Ethernet-Server",
    ethernet_interface="eth0",
)
app = BACnetApplication(config)
await app.start()

# Create objects, register DefaultServerHandlers, then wait for requests

BACnet Secure Connect

sc_server.py

Run a BACnet/SC server with BACnetApplication and DefaultServerHandlers for full APDU dispatch. This is the high-level approach – the server runs an SC hub function that accepts WebSocket connections from SC nodes:

from bac_py.app.application import BACnetApplication, DeviceConfig
from bac_py.transport.sc import SCTransportConfig
from bac_py.transport.sc.hub_function import SCHubConfig
from bac_py.transport.sc.tls import SCTLSConfig

sc_config = SCTransportConfig(
    hub_function_config=SCHubConfig(
        bind_address="0.0.0.0",
        bind_port=4443,
        tls_config=SCTLSConfig(allow_plaintext=True),
    ),
    tls_config=SCTLSConfig(allow_plaintext=True),
)
config = DeviceConfig(instance_number=1000, name="SC-Server", sc_config=sc_config)
app = BACnetApplication(config)
await app.start()

# Create objects, register DefaultServerHandlers, then wait for requests

secure_connect.py

Connect to an SC hub over WebSocket/TLS and send a ReadProperty request to a remote device addressed by its 6-byte VMAC. Demonstrates the lower-level SCTransport API with manual NPDU/APDU construction (see sc_server.py for the high-level approach):

from bac_py.transport.sc import SCTransport, SCTransportConfig
from bac_py.transport.sc.tls import SCTLSConfig
from bac_py.transport.sc.vmac import SCVMAC

config = SCTransportConfig(
    primary_hub_uri="ws://192.168.1.200:4443",
    tls_config=SCTLSConfig(allow_plaintext=True),
)
transport = SCTransport(config)
await transport.start()
await transport.hub_connector.wait_connected(timeout=10.0)

# Build and send NPDU to a remote VMAC
transport.send_unicast(npdu_bytes, SCVMAC.from_hex("02:AA:BB:CC:DD:01").address)

Install the secure extra (pip install bac-py[secure]) to use SC transport. See BACnet Secure Connect for the full guide.

secure_connect_hub.py

Run a BACnet/SC hub that accepts WebSocket connections from SC nodes, routes traffic between them, and optionally enables direct peer-to-peer connections via the Node Switch:

from bac_py.transport.sc import SCTransport, SCTransportConfig
from bac_py.transport.sc.hub_function import SCHubConfig
from bac_py.transport.sc.tls import SCTLSConfig

config = SCTransportConfig(
    hub_function_config=SCHubConfig(
        bind_address="0.0.0.0",
        bind_port=4443,
        tls_config=SCTLSConfig(allow_plaintext=True),
    ),
    tls_config=SCTLSConfig(allow_plaintext=True),
)
transport = SCTransport(config)
await transport.start()

# Hub is now accepting SC node connections on port 4443
print(f"Connected nodes: {transport.hub_function.connection_count}")

ip_to_sc_router.py

Bridge a BACnet/IP network and a BACnet/SC network with a pure-forwarding gateway router. Existing IP controllers communicate transparently with new SC devices – the router handles all NPDU forwarding in both directions:

from bac_py.network.router import NetworkRouter, RouterPort
from bac_py.transport.bip import BIPTransport
from bac_py.transport.sc import SCTransport, SCTransportConfig
from bac_py.transport.sc.tls import SCTLSConfig

# Port 1: BACnet/IP
bip_transport = BIPTransport(interface="0.0.0.0", port=0xBAC0)
await bip_transport.start()
bip_port = RouterPort(
    port_id=1, network_number=1, transport=bip_transport,
    mac_address=bip_transport.local_mac,
    max_npdu_length=bip_transport.max_npdu_length,
)

# Port 2: BACnet/SC
sc_transport = SCTransport(SCTransportConfig(
    primary_hub_uri="ws://192.168.1.200:4443",
    tls_config=SCTLSConfig(allow_plaintext=True),
))
sc_port = RouterPort(
    port_id=2, network_number=2, transport=sc_transport,
    mac_address=sc_transport.local_mac,
    max_npdu_length=sc_transport.max_npdu_length,
)

# Start the gateway (pure forwarding, no local application)
router = NetworkRouter([bip_port, sc_port])
await router.start()

sc_generate_certs.py

Generate a self-signed test PKI (CA + three device certificates for a hub and two nodes) and demonstrate TLS-secured SC communication with mutual authentication. Uses EC P-256 keys (the recommended curve for BACnet/SC) and the cryptography library that ships with bac-py[secure]. The demo starts a hub, connects two nodes via mutual TLS 1.3, and routes a test NPDU from node 1 to node 2 through the hub:

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID

# Generate CA key + self-signed certificate
ca_key = ec.generate_private_key(ec.SECP256R1())
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "BACnet Test CA")])
ca_cert = (
    x509.CertificateBuilder()
    .subject_name(ca_name)
    .issuer_name(ca_name)
    .public_key(ca_key.public_key())
    .serial_number(x509.random_serial_number())
    .not_valid_before(now)
    .not_valid_after(now + datetime.timedelta(days=365))
    .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
    .sign(ca_key, hashes.SHA256())
)

# Generate device certificate signed by the CA
device_key = ec.generate_private_key(ec.SECP256R1())
device_cert = (
    x509.CertificateBuilder()
    .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SC Hub")]))
    .issuer_name(ca_name)
    .public_key(device_key.public_key())
    .serial_number(x509.random_serial_number())
    .not_valid_before(now)
    .not_valid_after(now + datetime.timedelta(days=365))
    .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
    .sign(ca_key, hashes.SHA256())
)

# Wire into SCTLSConfig for mutual TLS
hub_tls = SCTLSConfig(
    private_key_path="hub.key",
    certificate_path="hub.crt",
    ca_certificates_path="ca.crt",
)
node_tls = SCTLSConfig(
    private_key_path="node1.key",
    certificate_path="node1.crt",
    ca_certificates_path="ca.crt",
)