Transport Setup

bac-py supports five BACnet transport types. This guide shows how to configure each one for common deployment topologies: standalone clients, servers, cross-subnet communication via BBMD, multi-network routing, raw Ethernet, and BACnet Secure Connect.

All transports share the same application layer – services, objects, and encoding work identically regardless of the underlying transport. Switching transports requires only configuration changes, not application code changes.

BACnet/IP (UDP)

The default transport. BACnet/IP (Annex J) uses UDP on port 47808 (0xBAC0) with broadcast for discovery and unicast for point-to-point communication.

Client

The simplest setup – a client that reads from devices on the local subnet:

import asyncio
from bac_py import Client

async def main():
    async with Client(instance_number=999) as client:
        value = await client.read("192.168.1.100", "ai,1", "pv")
        print(f"Temperature: {value}")

asyncio.run(main())

Bind to a specific interface when the host has multiple NICs:

async with Client(instance_number=999, interface="192.168.1.50") as client:
    ...

Server

A BACnet/IP server that exposes objects to the network:

import asyncio
from bac_py import BACnetApplication, DefaultServerHandlers, DeviceConfig, DeviceObject
from bac_py.objects.analog import AnalogInputObject
from bac_py.types.enums import EngineeringUnits

async def serve():
    config = DeviceConfig(
        instance_number=100,
        name="My-Device",
        vendor_name="ACME",
        vendor_id=999,
        interface="0.0.0.0",
        port=0xBAC0,
    )

    async with BACnetApplication(config) as app:
        device = DeviceObject(
            instance_number=100,
            object_name="My-Device",
            vendor_name="ACME",
            vendor_identifier=999,
        )
        app.object_db.add(device)
        app.object_db.add(AnalogInputObject(
            instance_number=1,
            object_name="Zone-Temp",
            units=EngineeringUnits.DEGREES_CELSIUS,
            present_value=22.5,
        ))

        handlers = DefaultServerHandlers(app, app.object_db, device)
        handlers.register()
        await app.run()

asyncio.run(serve())

See Server Mode for the full server guide including object database management, custom handlers, event engine, scheduling, trend logging, and audit logging.

Combined client and server

A single application can act as both client and server:

from bac_py.app.client import BACnetClient

async with BACnetApplication(config) as app:
    # Server side
    device = DeviceObject(instance_number=100, ...)
    app.object_db.add(device)
    handlers = DefaultServerHandlers(app, app.object_db, device)
    handlers.register()

    # Client side
    bc = BACnetClient(app)
    value = await bc.read("192.168.1.200", "ai,1", "pv")

BACnet/IP with BBMD

BACnet Broadcast Management Devices (BBMDs) enable communication across IP subnets. Without a BBMD, Who-Is broadcasts and other discovery messages stay within the local subnet.

Foreign device client

Register as a foreign device to discover and communicate with devices on the BBMD’s subnet:

from bac_py import Client

async with Client(
    instance_number=999,
    bbmd_address="192.168.1.1",
    bbmd_ttl=60,
) as client:
    # Discover devices on the BBMD's network
    devices = await client.discover(timeout=5.0)

    # Read from a device on the remote subnet
    value = await client.read("192.168.1.100", "ai,1", "pv")

    # Read BBMD tables
    bdt = await client.read_bdt("192.168.1.1")
    fdt = await client.read_fdt("192.168.1.1")

The client automatically re-registers before the TTL expires. You can also register manually:

await client.register_as_foreign_device("192.168.1.1", ttl=60)

BBMD server

Attach a BBMD to a server application to manage foreign devices and forward broadcasts between subnets:

import asyncio
from bac_py import BACnetApplication, DefaultServerHandlers, DeviceConfig, DeviceObject

async def serve_with_bbmd():
    config = DeviceConfig(
        instance_number=100,
        name="BBMD-Device",
        vendor_name="ACME",
        vendor_id=999,
        interface="192.168.1.1",
        port=0xBAC0,
    )

    async with BACnetApplication(config) as app:
        device = DeviceObject(
            instance_number=100,
            object_name="BBMD-Device",
            vendor_name="ACME",
            vendor_identifier=999,
        )
        app.object_db.add(device)

        handlers = DefaultServerHandlers(app, app.object_db, device)
        handlers.register()

        # Attach BBMD functionality
        app._transport.attach_bbmd()

        await app.run()

asyncio.run(serve_with_bbmd())

IPv4 multicast (Annex J.8)

As an alternative to directed broadcast, enable IPv4 multicast using group 239.255.186.192:

config = DeviceConfig(
    instance_number=999,
    multicast_enabled=True,
)

BACnet/IP Router

A BACnet router bridges multiple BACnet networks, forwarding NPDUs between them. Each router port connects to a different network number.

Basic two-network router

Bridge two IP subnets with a router:

from bac_py import BACnetApplication, DeviceConfig
from bac_py.app.application import RouterConfig, RouterPortConfig

config = DeviceConfig(
    instance_number=999,
    router_config=RouterConfig(
        ports=[
            RouterPortConfig(
                port_id=0, network_number=1,
                interface="192.168.1.10", port=47808,
            ),
            RouterPortConfig(
                port_id=1, network_number=2,
                interface="10.0.0.10", port=47808,
            ),
        ],
        application_port_id=0,
    ),
)

async with BACnetApplication(config) as app:
    # Router is now forwarding between network 1 and network 2
    await app.run()

The application_port_id specifies which port the local application listens on for BACnet services. Set it to the port where you want the router’s own device object to be visible.

Client through a router

Discover and communicate with devices on remote networks through a router:

from bac_py import Client

async with Client(instance_number=998) as client:
    # Discover routers
    routers = await client.who_is_router_to_network(timeout=3.0)
    for r in routers:
        print(f"Router at {r.address}: networks={r.networks}")

    # Discover devices on a remote network
    devices = await client.discover(destination="2:*", timeout=5.0)
    for dev in devices:
        print(f"  Device {dev.instance} at {dev.address_str}")

    # Read from a device on the remote network using routed address
    value = await client.read("2:0A00000A:BAC0", "ai,1", "pv")

Routed addresses use the format network:hex_mac where the MAC is the device’s IP address and port encoded as hex. See Addressing for details.

Mixed-transport router

Route between different transport types (e.g., BACnet/IP and BACnet/SC):

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 = BIPTransport(interface="0.0.0.0", port=0xBAC0)
await bip.start()

# Port 2: BACnet/SC
sc = SCTransport(SCTransportConfig(
    primary_hub_uri="ws://192.168.1.200:4443",
    tls_config=SCTLSConfig(allow_plaintext=True),
))

router = NetworkRouter([
    RouterPort(port_id=1, network_number=1, transport=bip,
               mac_address=bip.local_mac,
               max_npdu_length=bip.max_npdu_length),
    RouterPort(port_id=2, network_number=2, transport=sc,
               mac_address=sc.local_mac,
               max_npdu_length=sc.max_npdu_length),
])
await router.start()

This enables BACnet/IP devices on network 1 to communicate transparently with BACnet/SC devices on network 2. See BACnet Secure Connect for the full ip_to_sc_router.py example.

BACnet/IPv6 (Annex U)

BACnet/IPv6 provides native IPv6 transport with multicast discovery and 3-byte VMAC virtual addressing.

IPv6 client

from bac_py import Client

async with Client(ipv6=True) as client:
    devices = await client.discover(timeout=5.0)

The default multicast group is ff02::bac0 (link-local). Use ff05::bac0 for site-local scope:

async with Client(
    ipv6=True,
    interface="fd00::1",
    multicast_address="ff05::bac0",
) as client:
    ...

IPv6 server

Run a BACnet/IPv6 server with full APDU dispatch using DeviceConfig:

from bac_py import BACnetApplication, DefaultServerHandlers, DeviceConfig, DeviceObject

config = DeviceConfig(
    instance_number=100,
    name="IPv6-Server",
    ipv6=True,
)

async with BACnetApplication(config) as app:
    device = DeviceObject(instance_number=100, object_name="IPv6-Server",
                          vendor_name="ACME", vendor_identifier=999)
    app.object_db.add(device)
    # ... add objects ...

    handlers = DefaultServerHandlers(app, app.object_db, device)
    handlers.register()
    await app.run()

See examples/ipv6_server.py for a complete example.

IPv6 foreign device

Register with an IPv6 BBMD using bracket notation:

async with Client(
    ipv6=True,
    bbmd_address="[fd00::1]:47808",
    bbmd_ttl=60,
) as client:
    devices = await client.discover(timeout=5.0)

IPv6 router port

Mix IPv4 and IPv6 on different router ports:

from bac_py.app.application import DeviceConfig, RouterConfig, RouterPortConfig

config = DeviceConfig(
    instance_number=999,
    router_config=RouterConfig(
        ports=[
            RouterPortConfig(port_id=0, network_number=1,
                             interface="192.168.1.10", port=47808),
            RouterPortConfig(port_id=1, network_number=2,
                             ipv6=True, port=47808),
        ],
        application_port_id=0,
    ),
)

BACnet Ethernet (ISO 8802-3)

Raw Ethernet transport for legacy BACnet installations using IEEE 802.3 frames with 802.2 LLC headers (Clause 7). This is a transport that bypasses IP entirely, sending BACnet packets directly on the LAN.

Platform requirements:

  • Linux: AF_PACKET / SOCK_RAW (requires CAP_NET_RAW or root)

  • macOS: BPF devices (/dev/bpf*); requires explicit ethernet_mac

Ethernet server

Run a BACnet Ethernet server with full APDU dispatch using DeviceConfig:

from bac_py import BACnetApplication, DefaultServerHandlers, DeviceConfig, DeviceObject

config = DeviceConfig(
    instance_number=100,
    name="Ethernet-Server",
    ethernet_interface="eth0",
    # ethernet_mac=b"\x00\x11\x22\x33\x44\x55",  # required on macOS
)

async with BACnetApplication(config) as app:
    device = DeviceObject(instance_number=100, object_name="Ethernet-Server",
                          vendor_name="ACME", vendor_identifier=999)
    app.object_db.add(device)
    # ... add objects ...

    handlers = DefaultServerHandlers(app, app.object_db, device)
    handlers.register()
    await app.run()

See examples/ethernet_server.py for a complete example.

Low-level transport

For direct transport access without BACnetApplication:

from bac_py.transport.ethernet import EthernetTransport

transport = EthernetTransport(
    interface="eth0",
    mac_address=b"\x00\x11\x22\x33\x44\x55",  # optional on Linux
)
await transport.start()

Ethernet MAC addresses are supported in address strings:

from bac_py.network.address import parse_address

addr = parse_address("aa:bb:cc:dd:ee:ff")           # Local Ethernet
addr = parse_address("5:aa:bb:cc:dd:ee:ff")          # Remote on network 5
addr = parse_address("4352:01")                       # MS/TP 1-byte MAC

BACnet Secure Connect (Annex AB)

BACnet/SC replaces broadcast UDP with TLS-secured WebSocket connections in a hub-and-spoke topology. It traverses firewalls and NAT without BBMD infrastructure. Install the secure extra: pip install bac-py[secure].

SC server with BACnetApplication

The recommended approach: run an SC hub with full APDU dispatch using DeviceConfig(sc_config=...):

from bac_py import BACnetApplication, DefaultServerHandlers, DeviceConfig, DeviceObject
from bac_py.transport.sc import SCTransportConfig
from bac_py.transport.sc.hub_function import SCHubConfig
from bac_py.transport.sc.tls import SCTLSConfig

tls = SCTLSConfig(
    ca_certificates_path="/path/to/ca.pem",
    certificate_path="/path/to/hub.pem",
    private_key_path="/path/to/hub.key",
)

config = DeviceConfig(
    instance_number=100,
    name="SC-Server",
    sc_config=SCTransportConfig(
        hub_function_config=SCHubConfig(
            bind_address="0.0.0.0",
            bind_port=8443,
            tls_config=tls,
        ),
        tls_config=tls,
    ),
)

async with BACnetApplication(config) as app:
    device = DeviceObject(instance_number=100, object_name="SC-Server",
                          vendor_name="ACME", vendor_identifier=999)
    app.object_db.add(device)
    # ... add objects ...

    handlers = DefaultServerHandlers(app, app.object_db, device)
    handlers.register()
    await app.run()

See examples/sc_server.py for a complete example.

SC client with Client

Connect to an existing SC hub using the high-level Client:

from bac_py import Client
from bac_py.transport.sc import SCTransportConfig
from bac_py.transport.sc.tls import SCTLSConfig

sc_config = SCTransportConfig(
    primary_hub_uri="wss://hub.example.com:8443",
    tls_config=SCTLSConfig(
        ca_certificates_path="/path/to/ca.pem",
        certificate_path="/path/to/device.pem",
        private_key_path="/path/to/device.key",
    ),
)

async with Client(instance_number=999, sc_config=sc_config) as client:
    devices = await client.discover(timeout=5.0)
    value = await client.read("...", "ai,1", "pv")

SC client (low-level hub connector)

For direct transport access without BACnetApplication:

Connect to an existing SC hub:

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

config = SCTransportConfig(
    primary_hub_uri="wss://hub.example.com:8443",
    tls_config=SCTLSConfig(
        ca_certificates_path="/path/to/ca.pem",
        certificate_path="/path/to/device.pem",
        private_key_path="/path/to/device.key",
    ),
)
transport = SCTransport(config)
await transport.start()
await transport.hub_connector.wait_connected(timeout=10.0)

SC hub server (low-level)

Run a hub using the low-level transport API (no APDU dispatch – use the BACnetApplication approach above for a full server):

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

tls = SCTLSConfig(
    ca_certificates_path="/path/to/ca.pem",
    certificate_path="/path/to/hub.pem",
    private_key_path="/path/to/hub.key",
)
config = SCTransportConfig(
    hub_function_config=SCHubConfig(
        bind_address="0.0.0.0",
        bind_port=8443,
        tls_config=tls,
    ),
    tls_config=tls,
)
transport = SCTransport(config)
await transport.start()

SC with failover

Configure primary and failover hubs for continuous operation:

config = SCTransportConfig(
    primary_hub_uri="wss://hub1.example.com:8443",
    failover_hub_uri="wss://hub2.example.com:8443",
    tls_config=SCTLSConfig(...),
)

SC with direct peer connections

Enable the Node Switch for direct peer-to-peer WebSocket connections that bypass the hub:

from bac_py.transport.sc.node_switch import SCNodeSwitchConfig

config = SCTransportConfig(
    primary_hub_uri="wss://hub.example.com:8443",
    node_switch_config=SCNodeSwitchConfig(
        enable=True,
        bind_address="0.0.0.0",
        bind_port=8444,
    ),
    tls_config=SCTLSConfig(...),
)

For a complete BACnet/SC guide including TLS certificate generation, VMAC addressing, and address resolution, see BACnet Secure Connect.

Transport Comparison

Transport

Protocol

Discovery

Encryption

Cross-subnet

Use case

BACnet/IP

UDP

Broadcast

None

BBMD required

Standard BACnet networks

BACnet/IPv6

UDP/IPv6

Multicast

None

BBMD6

IPv6-only networks

Ethernet

802.3 LLC

Broadcast

None

Router required

Legacy installations

BACnet/SC

WebSocket/TLS

Via hub

TLS 1.3

NAT traversal

IT-managed, cloud-ready

Router

Mixed

Forwarded

Per-port

Native

Multi-network bridging