Barionet

BACnet/IP App

Version

Changelog

Date

Download

1.0.0

Initial Release

bacnet-io-server-v1.0.0.zip

Application Version: 1.0.0
Compatible Firmware: Flexa Firmware min. v2.3.4 (click to download)
Device Compatibility: Barionet M44, UX8 Extension

IMPORTANT NOTICE

This implementation supports up to 4 UX8 extension modules, with corresponding addresses reserved in the IO addressing table. However, at the time of this documentation, only configurations with up to 1 UX8 extension have been fully tested and validated. While the system is designed to handle 4 extensions, configurations using 2 or more UX8 modules have not yet undergone comprehensive testing.

Overview

A reference Flexa App that turns a Barix Barionet M44 into a BACnet/IP device. A
building management system can discover it, read every input, command the relays through a
standard priority array, and subscribe to Change of Value notifications.

How it fits together

   BMS / BACnet client  ──BACnet/IP──▶  BACNET_IO_SERVER  ──D-Bus──▶  io-mapping  ──▶  hardware
       (client)             UDP :47808      (this app)                 (firmware)      relays, DIs,
                                                                                       analogs, 1-Wire

The M44 firmware has no BACnet service of its ownio-mapping is the abstraction
layer that gives every physical and virtual I/O a numeric address. This app publishes those
addresses as BACnet objects.

Object instance number = io-mapping address

There is no translation table. Relay 1 is io-mapping address 1 and binaryOutput:1.
Digital input 2 is address 202 and binaryInput:202. Temperature sensor 3 is address 603
and analogInput:603. Anyone holding the io-mapping documentation can predict every object
in the device, and the two never drift apart.

See https://help.barix.com/barionet/io-addresses

Two value flows, deliberately different

  • Read-only objects (Binary Input, Analog Input) are refreshed from the I/O cache by a
    recurring task. Because the update goes through the normal property machinery, bacpypes
    raises COV notifications for free.

  • Writable objects are owned by the BACnet side. A property monitor on presentValue
    drives io-mapping whenever the effective value changes — whatever caused it: a client
    write, a priority-array resolution, or the watchdog.

An equality guard in the monitor stops the two flows fighting: a value that arrived from
the hardware is never written straight back to it. That is what lets a virtual bit changed
by another app on the device show up in BACnet, while a BACnet write still reaches the
hardware.

Reads are cached, writes are not

One iomapping.read_value() costs ~3.6 ms (a D-Bus round trip); a batched read_values()
costs ~0.7 ms per address. A background thread keeps the cache so the BACnet task loop is
never blocked on D-Bus. Writes go straight through and then update the cache.

Object map

Instance numbers below are the io-mapping addresses. Blocks marked configurable are sized
in the Exposed Objects section of the config tab; UX8 blocks appear only when the module
is detected.

Binary Output — commandable, 16-level priority array

Instance

Object name

Group

1–4

Relay 1–4

relay

11–18

UX8-1 Relay 1–8

relay

19–26

UX8-2 Relay 1–8

relay

27–34

UX8-3 Relay 1–8

relay

35–42

UX8-4 Relay 1–8

relay

Binary Input — read-only

Instance

Object name

201–204

Digital Input 1–4

211–218

UX8-1 Digital Input 1–8

219–226

UX8-2 Digital Input 1–8

227–234

UX8-3 Digital Input 1–8

235–242

UX8-4 Digital Input 1–8

209

RS232 CTS Input

60007–60010

UX8-1..4 Detected

9002

Watchdog Tripped (app)

Analog Input — read-only

Instance

Object name

Units

501–504

Analog Input 1–4

volts

511–542

UX8-1..4 Analog Input 1–8

volts

601–650

Temperature Sensor 1–50 (configurable)

degreesCelsius

401–404

Input Counter 1–4

noUnits

1201

Supply Current

milliamperes

1202

Supply Voltage

volts

1203

CPU Temperature

degreesCelsius

1204

Device Uptime

seconds

9001

Seconds Since Last BACnet Request (app)

seconds

Binary Value — writable, no priority array

Instance

Object name

Group

43–72

Virtual Bit 1–30 (configurable)

virtual

301–304

Input Pullup 1–4

pullup

1207

USB Ports Enable

system

Analog Value — commandable, 16-level priority array

Instance

Object name

Group

543–572

Virtual Register 1–30 (configurable)

virtual

1208–1209

User LED 1/2 Colour (1=red 2=green 3=yellow 4=blue 5=purple 6=cyan 7=white)

system

1210–1211

User LED 1/2 Brightness (0–15)

system

Value conventions

BACnet presentValue is a Real, so unlike Modbus registers these are engineering values,
not raw counts: analog inputs are volts, temperatures are °C, supply voltage is
volts. Scaling happens in the app.

Hardware type and firmware version are not exposed as objects — they are already on the
device object as modelName and firmwareRevision, which is where a BMS looks for them.

Installation Process

Installing the application requires uploading the package zipped file directly to your Barionet device through its web interface. This process is straightforward but requires careful attention to ensure proper installation.

NOTE: If your Barionet M44 is already running an application you must RESET TO DEFAULTS before proceeding and installing a new one.

Step 1: Access the Device Web Interface

  1. Open your web browser and navigate to your Barionet device's IP address

  2. Enter your administrator username and password when prompted

  3. Wait for the main interface to load completely before proceeding

    image-20250729-134518.png
    Barionet M44 User Interface

Step 2: Upload the Application Package

  1. Navigate to the HOME tab in the main menu

  2. Locate the "Upload" button next to “Install Package” within the page

  3. Click the file selection button and browse to the “<package>.zip” which was downloaded from this page

  4. Choose an appropriate version number for tracking purposes (this can be any number you prefer for identification)

  5. Click the UPLOAD button to begin the installation process

Step 3: Device Reboot and Verification

After the upload completes, your Barionet device will automatically initiate a reboot sequence. This reboot is necessary to properly integrate the new application into the system. The reboot process typically takes 30..40s

  1. Wait for the device to complete its reboot cycle

  2. Reconnect to the web interface using the same IP address and credentials

  3. Navigate back to the HOME tab

  4. Verify that the application status appears with a status of "running"

    image-20260817-125005.png

Configuration Overview

Once successfully installed, the configuration interface becomes available through the Application tab in your device's web interface. This dedicated configuration page provides access to all settings necessary to establish communication between the device and the server.

The configuration system is designed to be intuitive, each setting includes helpful descriptions and validation to prevent configuration errors that could disrupt communication.

image-20260817-125041.png

Configuration (web UI → BACnet tab)

Submitting the form writes config.json and restarts the app, which drops BACnet
connections and re-registers with any BBMD. Section keys become nested objects, so UDP
port lands at AppParam.network.udp_port.

BACnet Device

Enable BACnet devicedevice.enable, default on. Turns the server off without
uninstalling. When off the app idles rather than exiting, because a Flexa app that exits is
restarted by the platform watchdog in a loop.

Device instance numberdevice.device_instance, 0–4194302, default 44001. The
device object instance. It must be unique across the entire BACnet internetwork — a
duplicate is the single most common BACnet commissioning fault, and the symptom is erratic
discovery rather than a clean error. A common convention is to derive it from the IP address
or panel number.

Device namedevice.device_name, default Barionet-M44. Shown in discovery scans;
must also be unique on the internetwork.

Location / Descriptiondevice.location, device.description. Free text exposed
as the matching device object properties. Worth filling in: they are what an engineer sees
in a scan when trying to work out which panel a device is in.

Vendor identifierdevice.vendor_identifier, default 0. The ASHRAE-assigned
vendor ID. Leave at 0 unless you have your own; some BMS tools group devices by vendor.

Network

Network interfacenetwork.interface, default eth0. BACnet/IP needs the subnet
prefix to compute the local broadcast address used by Who-Is and I-Am, so the app reads the
address and prefix from this interface. If it has no IPv4 address the app logs an error
and stops rather than binding something wrong.

UDP portnetwork.udp_port, default 47808 (0xBAC0). Every device on the same
BACnet network must agree; change only if your site uses a non-default port.

Register with a BBMD (Foreign Device)network.enable_bbmd, default off. BACnet
discovery relies on broadcasts, which IP routers do not forward. If the BMS is on another
subnet, enable this and give the BBMD address (network.bbmd_address, as IP:port) so
the device registers as a Foreign Device and broadcasts reach it. Registration lifetime
(network.bbmd_ttl, default 300 s) is how long the BBMD holds the registration; the app
renews it automatically. On a flat single-subnet site, leave this off.

Access Control

Denied writes return error class property, code writeAccessDenied, and are logged at
WARNING with the object name. Reads and COV subscriptions are never restricted.

Read-only modeaccess.read_only, default off. Master switch; rejects every
WriteProperty regardless of the group switches. Use it when the Barionet is purely a data
source and must never be actuated from the network.

Allow relay writesaccess.allow_relay_writes, default on. The Binary Output
objects. On by default because commanding relays is the usual reason to install this app.

Allow pull-up writesaccess.allow_pullup_writes, default off. Binary Value
301–304. Off by default because changing a pull-up alters how its input reads — a client
writing these can silently change the meaning of your input data.

Allow virtual IO writesaccess.allow_virtual_writes, default on. Virtual Binary
Value and Analog Value objects. Nothing physical can move, so this is safe to leave on; they
are useful as a handshake area between the BMS and other apps on the device.

Allow system writesaccess.allow_system_writes, default off. USB enable and the
user LED colour and brightness. Driving LED colour from the BMS to show plant state is a
genuinely useful trick, but enable it deliberately.

Communication Watchdog

This is the one place the app makes a decision of its own, and it differs from the
Modbus version in an important way: it does not write the hardware behind BACnet's back.
It commands the relay objects through the priority array at its own priority level, which
means:

  • a controller commanding at a higher priority (lower number) still wins;

  • relinquishing on recovery restores whatever the BMS had commanded, with no previous
    state to remember.

Enable watchdogwatchdog.enable, default off. A task checks once per second how
long since the last BACnet request of any kind.

Communication timeoutwatchdog.timeout_s, default 300 s. Set it well above your
BMS poll cycle. Note that clients using COV instead of polling may be legitimately silent
for long periods — size this for the quietest legitimate client, not the busiest.

Startup grace periodwatchdog.startup_grace_s, default 120 s. The app comes up
before the BMS has rediscovered it; without a grace period every restart would trip the
watchdog.

Safe state actionwatchdog.action, default Command all relays inactive. Options
are all inactive, all active (for fail-closed designs), or report only — which touches
nothing but still logs and still sets Binary Input 9002, useful for measuring how often
comms actually drop before committing to an action that moves hardware.

Watchdog command prioritywatchdog.priority, 1–16, default 10. The priority array
slot the watchdog writes. Controllers at a lower number override it; those at a higher
number are overridden by it. Avoid 1 and 2, which the standard reserves for life safety.

Exposed Objects

Every object appears in a BMS discovery scan, so these settings are about keeping the device
legible as much as about load.

Temperature sensors installedobjects.temp_sensor_count, 0–50, default 4.
Expose UX8 extension IOobjects.include_ux8, default Auto (only detected
modules). Detection happens at startup only — restart after adding or removing a module.
Virtual bits / Virtual registersobjects.virtual_bit_count,
objects.virtual_register_count, default 10 each.

COV incrementobjects.cov_increment, default 0.1. How far an Analog Input must
move before subscribed clients are notified, in that object's own units. Because analog
inputs are scaled to volts, 0.1 means 100 mV. Beware: the supply voltage on a real device
jitters by more than that, so a small increment makes it notify on every refresh. Raise it
if your COV traffic looks continuous.

IO Polling

IO poll intervalpolling.poll_interval_ms, default 250 ms. How often the
background thread refreshes the cache from io-mapping.

Object refresh intervalpolling.refresh_interval_ms, default 500 ms. How often
cached values are copied into the BACnet objects. This is what drives COV, so it sets the
fastest rate a subscriber can be told about a change; together with the poll interval it
bounds the worst-case age of any value.

Logging

Log levellogging.log_level, default INFO (startup detail, the full object map,
the access policy). Warning adds denied writes only. Debug adds per-write tracing.
bacpypes' own loggers are pinned at WARNING regardless, because at DEBUG they produce a
packet trace.

Enable Syslog / Syslog Serverlogging.enable_syslog, logging.syslog_address
(as IP:port, port defaults to 514). There is no SSH on a sealed production device, so this
is the only field debugging channel. A handler that cannot be created is logged locally and
the app carries on.

Quick check with a BACnet python client

Python
#!/usr/bin/env python3
"""Test / diagnostic client for BACNET_IO_SERVER on a Barix Barionet M44.

Dependency-free: speaks BACnet/IP directly over a UDP socket using only the
standard library, so it runs anywhere Python does -- no bacpypes, no BAC0.
It implements just enough of the protocol for commissioning:

    Who-Is / I-Am        device discovery
    ReadProperty         any property of any object
    WriteProperty        with priority, and Null to relinquish

Usage: python test_bacnet.py [device-ip] [device-instance] [relay-hold-seconds]

WARNING: the relay test physically actuates relays 1-4. It asks first.
"""

import socket
import struct
import sys
import time

HOST = sys.argv[1] if len(sys.argv) > 1 else "192.168.2.20"
DEVICE_INSTANCE = int(sys.argv[2]) if len(sys.argv) > 2 else 44001
HOLD_S = int(sys.argv[3]) if len(sys.argv) > 3 else 5
PORT = 47808

INTERACTIVE = sys.stdin is not None and sys.stdin.isatty()

# --- BACnet enumerations we need ------------------------------------------
OBJ = {"analogInput": 0, "analogOutput": 1, "analogValue": 2,
       "binaryInput": 3, "binaryOutput": 4, "binaryValue": 5, "device": 8}
OBJ_NAME = {v: k for k, v in OBJ.items()}

PROP = {"description": 28, "firmwareRevision": 44, "location": 58, "modelName": 70,
        "objectIdentifier": 75, "objectList": 76, "objectName": 77, "objectType": 79,
        "presentValue": 85, "priorityArray": 87, "relinquishDefault": 104,
        "statusFlags": 111, "units": 117, "vendorName": 121, "outOfService": 81}

UNITS = {2: "mA", 5: "V", 62: "degC", 73: "s", 95: "", 98: "%", 124: "mV"}
BINARY_PV = {0: "inactive", 1: "active"}

ERROR_CLASS = {0: "device", 1: "object", 2: "property", 3: "resources",
               4: "security", 5: "services", 6: "vt", 7: "communication"}
# Values taken from the BACnet ErrorCode enumeration, not guessed -- 40 and 37
# are easy to transpose, and mislabelling writeAccessDenied as valueOutOfRange
# sends you hunting for a data problem that does not exist.
ERROR_CODE = {0: "other", 9: "invalidDataType", 27: "readAccessDenied",
              31: "unknownObject", 32: "unknownProperty", 37: "valueOutOfRange",
              40: "writeAccessDenied", 42: "invalidArrayIndex",
              50: "propertyIsNotAnArray"}


# ==========================================================================
# Encoding helpers
# ==========================================================================


def object_id(obj_type, instance):
    return struct.pack(">I", (OBJ[obj_type] << 22) | instance)


def context_tag(number, data):
    """Context-specific tag with a length that fits in the tag byte."""
    return bytes([(number << 4) | 0x08 | len(data)]) + data


def bvlc(function, apdu):
    """Wrap an NPDU in a BVLL header. 0x0A = unicast, 0x0B = broadcast."""
    npdu = bytes([0x01, 0x04])          # version 1, expecting reply
    body = npdu + apdu
    return bytes([0x81, function]) + struct.pack(">H", len(body) + 4) + body


# ==========================================================================
# Decoding helpers
# ==========================================================================


def decode_value(data):
    """Decode one application-tagged primitive. Returns (value, bytes_consumed)."""
    if not data:
        return None, 0
    tag = data[0]
    tag_number, length = tag >> 4, tag & 0x07

    if tag_number == 0:                                   # Null
        return None, 1
    if tag_number == 1:                                   # Boolean
        return bool(length), 1
    if tag_number == 2:                                   # Unsigned
        return int.from_bytes(data[1:1 + length], "big"), 1 + length
    if tag_number == 3:                                   # Signed
        return int.from_bytes(data[1:1 + length], "big", signed=True), 1 + length
    if tag_number == 4:                                   # Real
        return struct.unpack(">f", data[1:5])[0], 5
    if tag_number == 7:                                   # CharacterString
        if length == 5:                                   # extended length
            length = data[1]
            return data[3:2 + length].decode("utf-8", "replace"), 2 + length
        return data[2:1 + length].decode("utf-8", "replace"), 1 + length
    if tag_number == 8:                                   # BitString
        return list(data[2:1 + length]), 1 + length
    if tag_number == 9:                                   # Enumerated
        return int.from_bytes(data[1:1 + length], "big"), 1 + length
    if tag_number == 12:                                  # ObjectIdentifier
        raw = struct.unpack(">I", data[1:5])[0]
        return (OBJ_NAME.get(raw >> 22, raw >> 22), raw & 0x3FFFFF), 5
    return ("<tag %d>" % tag_number), 1 + length


def decode_property(apdu, obj_type, prop):
    """Pull the value out of a ComplexAck between opening tag 3E and 3F."""
    start = apdu.find(b"\x3e")
    end = apdu.rfind(b"\x3f")
    if start < 0 or end < 0:
        return "<no value>"
    payload = apdu[start + 1:end]

    values = []
    offset = 0
    while offset < len(payload):
        value, used = decode_value(payload[offset:])
        if used <= 0:
            break
        values.append(value)
        offset += used

    if not values:
        return "<empty>"
    value = values[0] if len(values) == 1 else values

    # friendly rendering for the enumerations we care about
    if prop in ("presentValue", "relinquishDefault") and obj_type.startswith("binary"):
        return BINARY_PV.get(value, value)
    if prop == "units":
        return UNITS.get(value, "unit %s" % value)
    return value


def decode_error(apdu):
    """Errors are two enumerated values: class then code."""
    values, offset = [], 3
    while offset < len(apdu) and len(values) < 2:
        value, used = decode_value(apdu[offset:])
        values.append(value)
        offset += used
    if len(values) < 2:
        return "error (undecodable)"
    return "%s: %s" % (ERROR_CLASS.get(values[0], values[0]),
                       ERROR_CODE.get(values[1], values[1]))


# ==========================================================================
# The client
# ==========================================================================


class Bacnet:
    def __init__(self, host, port=PORT, timeout=3.0):
        self.host, self.port, self.timeout = host, port, timeout
        self.invoke_id = 0
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        try:
            self.sock.bind(("0.0.0.0", PORT))
        except OSError:
            self.sock.bind(("0.0.0.0", 0))    # 47808 busy locally, any port works
        self.sock.settimeout(timeout)

    def close(self):
        self.sock.close()

    def _next_id(self):
        self.invoke_id = (self.invoke_id + 1) % 256
        return self.invoke_id

    def _request(self, service, params, expect_ack=False):
        invoke_id = self._next_id()
        apdu = bytes([0x00, 0x04, invoke_id, service]) + params
        self.sock.sendto(bvlc(0x0A, apdu), (self.host, self.port))

        deadline = time.time() + self.timeout
        while time.time() < deadline:
            try:
                data, _addr = self.sock.recvfrom(1500)
            except socket.timeout:
                break
            if len(data) < 7 or data[0] != 0x81:
                continue
            reply = data[4 + 2:]                       # skip BVLC + NPDU
            pdu_type = reply[0] >> 4
            if pdu_type == 3 and reply[1] == invoke_id:        # ComplexAck
                return ("ok", reply)
            if pdu_type == 2 and reply[1] == invoke_id:        # SimpleAck
                return ("ack", reply)
            if pdu_type == 5 and reply[1] == invoke_id:        # Error
                return ("error", reply)
            if pdu_type == 6:                                  # Reject
                return ("reject", reply)
            if pdu_type == 7:                                  # Abort
                return ("abort", reply)
        return ("timeout", b"")

    # -- services -----------------------------------------------------------
    def who_is(self, broadcast_address):
        """Send Who-Is and collect every I-Am that arrives for 3 seconds."""
        apdu = bytes([0x10, 0x08])                    # Unconfirmed-Request, who-Is
        packet = bytes([0x81, 0x0B, 0x00, 0x08, 0x01, 0x00]) + apdu
        self.sock.sendto(packet, (broadcast_address, self.port))

        found, deadline = [], time.time() + 3
        while time.time() < deadline:
            try:
                data, addr = self.sock.recvfrom(1500)
            except socket.timeout:
                continue
            index = data.find(b"\xC4")
            if index < 0 or len(data) < index + 5:
                continue
            raw = struct.unpack(">I", data[index + 1:index + 5])[0]
            if raw >> 22 == OBJ["device"]:
                found.append((addr[0], raw & 0x3FFFFF))
        return found

    def read(self, obj_type, instance, prop="presentValue", index=None):
        params = context_tag(0, object_id(obj_type, instance))
        params += context_tag(1, bytes([PROP[prop]]))
        if index is not None:
            params += context_tag(2, bytes([index]))
        status, reply = self._request(0x0C, params)
        if status == "ok":
            return decode_property(reply, obj_type, prop)
        if status == "error":
            return "ERROR %s" % decode_error(reply)
        return "ERROR %s" % status

    def write(self, obj_type, instance, value, prop="presentValue", priority=None):
        """value: True/False for binary, float for analog, None to relinquish."""
        if value is None:
            encoded = bytes([0x00])                                   # Null
        elif obj_type.startswith("binary"):
            encoded = bytes([0x91, 1 if value else 0])                # Enumerated
        else:
            encoded = bytes([0x44]) + struct.pack(">f", float(value))  # Real

        params = context_tag(0, object_id(obj_type, instance))
        params += context_tag(1, bytes([PROP[prop]]))
        params += bytes([0x3E]) + encoded + bytes([0x3F])
        if priority is not None:
            params += context_tag(4, bytes([priority]))

        status, reply = self._request(0x0F, params)
        if status == "ack":
            return "ack"
        if status == "error":
            return "ERROR %s" % decode_error(reply)
        return "ERROR %s" % status


# ==========================================================================
# Test sections
# ==========================================================================


def ask(prompt):
    try:
        return input(prompt + " [Enter = go, s = skip] ").strip().lower() != "s"
    except (EOFError, KeyboardInterrupt):
        print()
        return False


def discovery(client):
    print("--- discovery ---")
    broadcast = HOST.rsplit(".", 1)[0] + ".255"
    print("sending Who-Is to %s ..." % broadcast)
    for address, instance in client.who_is(broadcast):
        marker = "  <-- target" if instance == DEVICE_INSTANCE else ""
        print("   I-Am  device %-8d from %s%s" % (instance, address, marker))
    print()


def snapshot(client):
    print("--- device object ---")
    for prop in ("objectName", "vendorName", "modelName", "firmwareRevision",
                 "location", "description"):
        print("   %-18s %s" % (prop, client.read("device", DEVICE_INSTANCE, prop)))

    print("\n--- inputs ---")
    for instance in (201, 202, 203, 204):
        print("   binaryInput %-6d %-22s %s"
              % (instance, client.read("binaryInput", instance, "objectName"),
                 client.read("binaryInput", instance)))
    for instance in (501, 502):
        print("   analogInput %-6d %-22s %s %s"
              % (instance, client.read("analogInput", instance, "objectName"),
                 client.read("analogInput", instance),
                 client.read("analogInput", instance, "units")))

    print("\n--- system telemetry ---")
    for instance in (1201, 1202, 1203, 1204):
        print("   analogInput %-6d %-22s %s %s"
              % (instance, client.read("analogInput", instance, "objectName"),
                 client.read("analogInput", instance),
                 client.read("analogInput", instance, "units")))

    print("\n--- app diagnostics ---")
    print("   analogInput 9001   idle seconds           %s" % client.read("analogInput", 9001))
    print("   binaryInput 9002   watchdog tripped       %s" % client.read("binaryInput", 9002))

    print("\n--- relays ---")
    for instance in (1, 2, 3, 4):
        print("   binaryOutput %-5d %-22s pv=%-9s relinquishDefault=%s"
              % (instance, client.read("binaryOutput", instance, "objectName"),
                 client.read("binaryOutput", instance),
                 client.read("binaryOutput", instance, "relinquishDefault")))
    print()


def write_policy_test(client):
    print("--- write policy ---")
    print("   pull-up  binaryValue 301 write :", client.write("binaryValue", 301, False))
    print("   pull-up  binaryValue 301 read  :", client.read("binaryValue", 301),
          "(reads are never restricted)")
    print("   virtual  binaryValue 43  write :", client.write("binaryValue", 43, True))
    print("   virtual  binaryValue 43  read  :", client.read("binaryValue", 43))
    client.write("binaryValue", 43, False)
    print()


def relay_test(client):
    print("--- relay test: commandable priority array ---")
    print("Switches relays 1-4 on at priority 8 for %d s, then relinquishes." % HOLD_S)
    if INTERACTIVE and not ask("Energise relays 1-4?"):
        print("skipped.\n")
        return

    for instance in (1, 2, 3, 4):
        result = client.write("binaryOutput", instance, True, priority=8)
        if result != "ack":
            print("   relay %d write refused: %s" % (instance, result))
            print("   (check Access Control in the BACnet tab)")
            return
    time.sleep(0.5)
    print("   on  :", [client.read("binaryOutput", i) for i in (1, 2, 3, 4)])

    try:
        for remaining in range(HOLD_S, 0, -1):
            print("   holding... %d s " % remaining, end="\r", flush=True)
            time.sleep(1)
        print(" " * 24, end="\r")
    finally:
        # Relinquish rather than command "inactive": that hands the relay back
        # to whatever else was commanding it, which is the BACnet way.
        for instance in (1, 2, 3, 4):
            client.write("binaryOutput", instance, None, priority=8)
    time.sleep(0.5)
    print("   off :", [client.read("binaryOutput", i) for i in (1, 2, 3, 4)])
    print()


def main():
    print("BACnet/IP -> %s:%d, device instance %d\n" % (HOST, PORT, DEVICE_INSTANCE))
    client = Bacnet(HOST)
    try:
        discovery(client)
        snapshot(client)
        write_policy_test(client)
        relay_test(client)
    finally:
        client.close()


if __name__ == "__main__":
    main()

The test client speaks BACnet/IP directly over a UDP socket using only the standard library — no bacpypes, no BAC0, nothing to install. It implements Who-Is/I-Am, ReadProperty and WriteProperty (including priority and Null-to-relinquish):

Bash
python test_bacnet.py 192.168.2.20 44001 5

Arguments are device IP, device instance, and relay hold seconds. It runs discovery, dumps
the device object and a sample of each object type, checks the write policy, and offers a
relay test that commands relays 1–4 at priority 8 and then relinquishes rather than
commanding them off — the BACnet-correct way to hand control back.Verified behaviour

Gotcha worth knowing: @register_object_type is not optional

bacpypes builds each class's property dictionary inside register_object_type(), walking
__mro__ with first-definition-wins. A class that is never registered simply inherits its
parent's dictionary.

bacpypes 0.19 does not register AnalogValueCmdObject, so it inherits
AnalogValueObject's dictionary — which was built before the Commandable mixin existed in
the MRO and marks presentValue read-only. The symptom is a WriteProperty that fails
with writeAccessDenied for reasons nothing in your own code can explain, while the
identical pattern works fine for Binary Output (whose base class already has a writable
presentValue).

Registering your own subclasses rebuilds the dictionary over the full MRO and the mixin's
WritableProperty wins. Every object class in main.py carries the decorator for this
reason.

Extending it

  • Change the object map — edit BLOCKS in main.py. One table drives the objects, the
    write policy, the poll list and the startup log.

  • New write group — add a group name to a block, to WritePolicy.allowed, and a
    checkbox to the Access Control section of config/sdf.json.

  • Relay feedback — Binary Output presentValue reflects the priority array, not the
    physical contact. If another app on the device drives a relay directly, the object will
    not show it. Expose feedback as separate Binary Input objects if you need it.

  • Intrinsic alarming — the objects already carry statusFlags and eventState; adding
    highLimit/lowLimit and event enrolment would let the device generate alarms itself
    rather than relying on the BMS to notice.

  • Trend logs — bacpypes has TrendLogObject; combined with the existing cache it would
    let the device buffer history across a BMS outage.

Software Disclaimer

IMPORTANT:

This software application ("Software") is provided "AS IS" without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the author, developer, or distributor be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.

Use at Your Own Risk: The user assumes all responsibility and risk for the use of this Software. The author makes no representations or warranties regarding the accuracy, reliability, completeness, or timeliness of the Software or its suitability for any particular purpose.

No Support Obligation: The provision of this Software does not create any obligation to provide technical support, maintenance, updates, enhancements, or modifications. The Software is provided for educational and reference purposes only.

Third-Party Dependencies: This Software may utilize third-party libraries and components. The user is responsible for ensuring compliance with all applicable licenses and terms of use for such dependencies.

Network and Security: The user is solely responsible for implementing appropriate security measures and network configurations. The author is not responsible for any security vulnerabilities, data breaches, or network disruptions that may result from the use of this Software.

Compliance: Users are responsible for ensuring that their use of this Software complies with all applicable laws, regulations, and organizational policies in their jurisdiction.

Limitation of Liability: Under no circumstances shall the total liability of the author exceed zero dollars ($0.00) for any damages arising out of or related to the use of this Software.

By using this Software, you acknowledge that you have read, understood, and agree to be bound by the terms of this disclaimer.