|
Version |
Changelog |
Date |
Download |
|---|---|---|---|
|
1.0.0 |
Initial Release |
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 own — io-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
-
Open your web browser and navigate to your Barionet device's IP address
-
Enter your administrator username and password when prompted
-
Wait for the main interface to load completely before proceeding
Barionet M44 User Interface
Step 2: Upload the Application Package
-
Navigate to the HOME tab in the main menu
-
Locate the "Upload" button next to “Install Package” within the page
-
Click the file selection button and browse to the “<package>.zip” which was downloaded from this page
-
Choose an appropriate version number for tracking purposes (this can be any number you prefer for identification)
-
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
-
Wait for the device to complete its reboot cycle
-
Reconnect to the web interface using the same IP address and credentials
-
Navigate back to the HOME tab
-
Verify that the application status appears with a status of "running"
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.
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 device — device.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 number — device.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 name — device.device_name, default Barionet-M44. Shown in discovery scans;
must also be unique on the internetwork.
Location / Description — device.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 identifier — device.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 interface — network.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 port — network.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 mode — access.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 writes — access.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 writes — access.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 writes — access.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 writes — access.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 watchdog — watchdog.enable, default off. A task checks once per second how
long since the last BACnet request of any kind.
Communication timeout — watchdog.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 period — watchdog.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 action — watchdog.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 priority — watchdog.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 installed — objects.temp_sensor_count, 0–50, default 4.
Expose UX8 extension IO — objects.include_ux8, default Auto (only detected
modules). Detection happens at startup only — restart after adding or removing a module.
Virtual bits / Virtual registers — objects.virtual_bit_count,
objects.virtual_register_count, default 10 each.
COV increment — objects.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 interval — polling.poll_interval_ms, default 250 ms. How often the
background thread refreshes the cache from io-mapping.
Object refresh interval — polling.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 level — logging.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 Server — logging.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
#!/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):
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
BLOCKSinmain.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 ofconfig/sdf.json. -
Relay feedback — Binary Output
presentValuereflects 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
statusFlagsandeventState; adding
highLimit/lowLimitand 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.