bacnet_plugin 0.9.0
bacnet_plugin: ^0.9.0 copied to clipboard
High-throughput BACnet/IP client and server for Dart and Flutter, built on bacnet-stack via FFI with an isolate based, non-blocking engine.
BACnet Plugin #
High-throughput BACnet/IP client and server for Dart and Flutter, built on bacnet-stack 1.7 through FFI. Works in Flutter apps (Android, iOS, Linux, macOS, Windows) and in plain Dart programs such as headless gateways and supervisory services.
Features #
- Client: Who-Is/I-Am discovery, ReadProperty, ReadPropertyMultiple (split automatically when the answer exceeds the device's APDU), segmented answers (large object lists, schedules, RPM results) reassembled transparently, WriteProperty(Multiple) with datatype inference, SubscribeCOV(Property) and SubscribeCOVPropertyMultiple with decoded notifications, ReadRange/Trend Logs, alarms and events (typed event notifications, AcknowledgeAlarm, GetEventInformation, GetAlarmSummary, GetEnrollmentSummary, Add/RemoveListElement), auditing (AuditNotification, AuditLogQuery), device management (DeviceCommunicationControl, ReinitializeDevice, Create/DeleteObject), file transfer (AtomicReadFile/AtomicWriteFile), private transfer, text messages, time synchronization, router and network discovery (Who-Is-Router-To-Network, What-Is-Network-Number, routing tables), foreign device registration, BBMD table management (Broadcast Distribution and Foreign Device Tables), backup and restore of devices, device provisioning (Who-Am-I/You-Are), WriteGroup and raw confirmed services.
- Server: hosts Analog/Binary/Multi-state Input/Output/Value, Integer, Positive Integer, CharacterString Value, Notification Class, File (content in memory), Schedule, Calendar, Trend Log, Channel, Lighting Output, Binary Lighting Output, Color, Color Temperature, Loop, Timer, Accumulator, Averaging, Load Control, Structured View, Event Enrollment, Audit Log and Audit Reporter objects; acts as a Time Master; audits client operations (AuditNotification, AuditLogQuery); answers Who-Is, Read/WriteProperty(Multiple), SubscribeCOV(Property)(Multiple), ReadRange, Add/RemoveListElement, AtomicReadFile/AtomicWriteFile, DeviceCommunicationControl and ReinitializeDevice (password protected, reported to the application, backup and restore) natively; reports alarms of analog and binary objects (intrinsic reporting) and Event Enrollments and answers AcknowledgeAlarm, GetEventInformation, GetAlarmSummary and GetEnrollmentSummary; WriteGroup; asks a supervisor for its device instance (Who-Am-I/You-Are); batch updates of present values; write notifications; state persistence (snapshot and restore its objects and values through a pluggable store); advertises itself as a router to virtual networks; runs over BACnet/IPv4 or, on POSIX, BACnet/IPv6.
- Built for load: request scheduler with global and per-device concurrency limits, back pressure, automatic address binding, concurrent reads merged into ReadPropertyMultiple, batched isolate messaging and zero-copy event processing.
- Tools: the
bacnetcommand line tool (discover, read, write, watch, describe) and device descriptions in JSON and EPICS notation. - Robust: bounds-checked decoder and native engine (fuzz tested), typed
exceptions,
protection against late replies with recycled invoke ids, no
exit()/longjmptricks in native code. - Modern build: native code is compiled by a build hook for every platform — no CMake, Gradle or CocoaPods configuration in the package.
Installation #
dependencies:
bacnet_plugin: ^0.9.0
Requirements:
- Dart 3.11+ / Flutter 3.44+ (build hooks).
- A C compiler on the build machine: Xcode (macOS/iOS), Visual Studio with the C++ workload (Windows), clang or gcc (Linux), the Android NDK installed by Flutter (Android).
- Git dependencies must be fetched with submodules
(
native/bacnet-stackis pinned to a reviewed bacnet-stack commit); the pub.dev package already contains the sources.
Platform notes:
- Android: add
android.permission.INTERNETto the app manifest. Some devices drop broadcast packets in power saving mode; acquire aWifiManager.MulticastLockif discovery is unreliable. - iOS 14+: add
NSLocalNetworkUsageDescriptiontoInfo.plist. - macOS: enable
com.apple.security.network.clientandcom.apple.security.network.serverentitlements. - Interface: pass an interface name (
eth0,en0) or an IPv4 address. On Windows only IPv4 addresses are accepted.
Quick start #
Client #
import 'package:bacnet_plugin/bacnet_plugin.dart';
Future<void> main() async {
final client = BacnetClient(
config: const BacnetConfig(interface: '192.168.1.100'),
);
await client.start();
client.iAmEvents.listen((iAm) {
print('device ${iAm.deviceId} at ${iAm.ipAddress}:${iAm.port}');
});
await client.sendWhoIs();
// Unknown devices are bound automatically with a targeted Who-Is.
final temperature = await client.readProperty(
1234,
BacnetObjectType.analogInput,
1,
BacnetPropertyId.presentValue,
);
print(temperature.asDouble); // null if the device sent another datatype
// The value class is the BACnet datatype; BacnetNull() relinquishes.
await client.writeProperty(
1234,
BacnetObjectType.analogOutput,
1,
BacnetPropertyId.presentValue,
const BacnetReal(21.5),
priority: 8,
);
await client.close();
}
Server #
final server = BacnetServer(config: const BacnetConfig(interface: 'eth0'));
await server.start();
await server.init(4194300, 'Gateway', vendorName: 'ACME');
await server.addObject(
BacnetObjectType.analogInput,
1,
name: 'Supply Air Temperature',
units: BacnetEngineeringUnits.degreesCelsius,
covIncrement: 0.1,
);
await server.addObject(
BacnetObjectType.multiStateValue,
1,
name: 'Mode',
stateTexts: ['Off', 'Heat', 'Cool'],
presentValue: const BacnetUnsigned(1),
);
// Push many values at once (one isolate message, one native call).
await server.updatePresentValues([
for (final point in points)
BacnetPresentValueUpdate(
objectType: BacnetObjectType.analogInput,
instance: point.instance,
value: BacnetReal(point.value),
),
]);
server.writeEvents.listen((write) {
print('${write.objectType}:${write.instance} <- ${write.value}');
});
A process hosts one BACnet stack: clients and servers created in the same process share it (reference counted, the first configuration wins).
High-load guide #
The worker isolate owns the native stack. It blocks in poll() on the
sockets (no timer polling) and is woken up immediately when the main isolate
sends work. Requests are queued per device and sent while transaction slots
are free:
| Setting | Default | Purpose |
|---|---|---|
maxConcurrentRequests |
200 | Outstanding confirmed requests (≤ 250 invoke ids). |
maxConcurrentRequestsPerDevice |
4 | Protects small controllers and MS/TP routers. |
maxQueuedRequests |
10 000 | Back pressure: excess calls fail fast with BacnetQueueFullException. |
coalesceReads |
true | Merges concurrent readProperty calls per device into ReadPropertyMultiple. |
maxCoalescedReads |
24 | Properties per merged request (lower it for MS/TP devices). |
requestTimeout |
30 s | Deadline including queueing and retries. |
apduTimeout / maxRetries |
3 s / 3 | Per transmission timeout and retries. |
bindTimeout |
5 s | Time to resolve an unknown device with Who-Is. |
offlineAfterTimeouts |
3 | Consecutive timeouts after which requests to a device fail fast. |
offlineRetryInterval |
30 s | Pause before an offline device is probed again (doubles up to 8×). |
maxSegmentsAccepted |
32 | Segments accepted per answer (0 disables segmented answers). |
socketBufferSize |
4 MiB | Avoids drops during I-Am storms and bursts. |
covScanInterval |
50 ms | Server side change-of-value detection. |
strictSourceCheck |
true | Drops replies whose source differs from the target. |
Recommendations:
- Fire requests concurrently (
Future.wait, streams); the scheduler keeps the network load within the limits and concurrent reads of a device travel together in ReadPropertyMultiple requests. - Prefer
readMultiplefor many properties of one device; oversized requests are split automatically. - Mark bulk work (
background: true,DeviceScannerdoes it by default): it waits behind interactive requests and never takes all transaction slots, so the UI stays responsive during a site scan. - Cancel requests nobody waits for any more with a
BacnetCancelToken(e.g. when a screen closes); queued requests are dropped. - Dead controllers do not slow down the rest of the site: after
offlineAfterTimeoutstimeouts their requests fail at once withBacnetDeviceOfflineExceptionuntil a probe or an I-Am succeeds. - Prefer COV (
PropertyMonitor) over polling; subscriptions are renewed and cancelled automatically, notifications carry the values. Devices with SubscribeCOVPropertyMultiple get one subscription for all their monitored properties. - On servers use
updatePresentValuesfor bulk updates. - Watch
client.stats()(queue length, in-flight requests, timeouts, dropped replies) in production.
Benchmarks #
Measured on a 4 vCPU Linux VM over the loopback interface (servers and the
load generator are separate processes built with dart build cli):
| Scenario | Result |
|---|---|
Client, 50 000 concurrent readProperty to 4 devices (merged into 2 084 RPM) |
170 000–196 000 reads/s, 0 errors |
Same with coalesceReads: false (50 000 ReadProperty) |
58 000–68 000 requests/s |
| Client, 10 000 ReadPropertyMultiple (20 values each) to 8 devices | 258 600 values/s |
| One server, 4 clients × 40 000 ReadProperty | ~128 000 requests/s, 0 errors, 10 MB RSS |
| Server, batch update of 10 000 present values | 2.5–3 ms (≈ 3.5 M values/s) |
Client vs bacnet-stack bacserv, 1 000 ReadProperty |
78 ms |
Run them yourself with benchmark/load_test.dart (modes rp, rpm and
write) and benchmark/server_benchmark.dart.
Architecture #
main isolate worker isolate native (C)
┌──────────────────────┐ commands ┌───────────────────────┐ FFI ┌────────────────────┐
│ BacnetClient │ ─────────► │ request scheduler │ ──► │ engine │
│ BacnetServer │ + wakeup │ (per device queues, │ │ (event buffer, │
│ DeviceScanner │ │ limits, binding) │ │ source checks, │
│ PropertyMonitor │ ◄───────── │ event decoding │ ◄── │ timers) │
└──────────────────────┘ batched └───────────────────────┘ │ bacnet-stack 1.7 │
results └────────────────────┘
- The C engine (
native/src) wraps bacnet-stack: confirmed requests are sent with pre-encoded service data, every network event is appended to an event buffer which Dart drains after each poll — no callbacks cross the FFI boundary. - Services are encoded/decoded in Dart (
lib/src/codec) with a bounds checked reader. - Server requests are answered by bacnet-stack without running Dart code.
- Segmented answers to client requests are reassembled by the engine
(
native/src/bp_segments.c), since the transaction state machine of bacnet-stack does not implement segmentation. The server cannot send segmented answers: requests whose answer exceeds the client's APDU are aborted with segmentation-not-supported.
Error handling #
try {
await client.readProperty(1234, BacnetObjectType.analogInput, 1,
BacnetPropertyId.presentValue);
} on BacnetProtocolException catch (e) {
print('${e.errorClass.label}: ${e.errorCode.label}');
} on BacnetRejectException catch (e) {
print('rejected: ${e.reason.label}');
} on BacnetAbortException catch (e) {
print('aborted: ${e.reason.label}');
} on BacnetDeviceNotFoundException catch (e) {
print('device ${e.deviceId} did not answer Who-Is');
} on BacnetDeviceOfflineException catch (e) {
print('device ${e.deviceId} is offline, retry in ${e.retryAfter}');
} on BacnetTimeoutException {
print('no answer');
} on BacnetQueueFullException {
print('slow down');
} on BacnetCancelledException {
// cancelled with a BacnetCancelToken
}
Identifiers #
Object types, property identifiers, units, error classes and the other
BACnet enumerations are extension types
over int: they cost nothing at runtime, work wherever an int is expected,
and the compiler rejects a property where an object type is expected.
Values this package does not name (proprietary types and properties) are
created explicitly:
await client.readProperty(
1234,
const BacnetObjectType(130), // proprietary object type
1,
const BacnetPropertyId(512), // proprietary property
);
print(BacnetObjectType.multiStateValue.label); // Multi-state Value
Values #
Property values are a sealed class, BacnetValue, with one subclass per
BACnet datatype. Nothing in the API is dynamic or Object?: reads say
what they return, writes say which datatype goes on the wire, and the
compiler checks that a switch handles every case.
| BACnet datatype | Class | Dart value |
|---|---|---|
| Null | BacnetNull |
|
| Boolean | BacnetBoolean |
bool |
| Unsigned / Signed | BacnetUnsigned / BacnetSigned |
int |
| Real / Double | BacnetReal / BacnetDouble |
double |
| OctetString | BacnetOctetString |
Uint8List |
| CharacterString | BacnetCharacterString |
String |
| BitString | BacnetBitString |
List<bool> |
| Enumerated | BacnetEnumerated |
int |
| Date / Time | BacnetDate / BacnetTime |
fields, null = unspecified |
| ObjectIdentifier | BacnetObject |
type, instance |
| array or list | BacnetList |
List<BacnetValue> |
| other constructed data | BacnetConstructedValue, BacnetContextValue |
raw |
final value = await client.readProperty(1234,
BacnetObjectType.analogInput, 1, BacnetPropertyId.presentValue);
switch (value) {
case BacnetReal(:final value):
print('$value °C');
case BacnetNull():
print('no value');
default:
print('unexpected $value');
}
// Accessors return null for any other datatype.
value.asDouble; // Real, Double, Unsigned, Signed
value.asInt; // Unsigned, Signed, Enumerated
value.asString; // CharacterString
value.asStatusFlags; // BitString as BacnetStatusFlags
value.asList; // elements of a BacnetList, or [value]
A device sends an array with one element exactly like a single value, so
use asList for arrays and lists (Object_List, Priority_Array, ...).
readMultiple returns, per object and property, a BacnetPropertyResult:
a BacnetValue or a BacnetError for a property the device could not
return.
const sensor =
BacnetObject(type: BacnetObjectType.analogInput, instance: 1);
final results = await client.readMultiple(1234, [
const BacnetReadAccessSpecification(
objectIdentifier: sensor,
properties: [
BacnetPropertyReference(
propertyIdentifier: BacnetPropertyId.objectName),
BacnetPropertyReference(
propertyIdentifier: BacnetPropertyId.presentValue),
],
),
]);
final properties = results[sensor] ?? const {};
print(properties.valueOf(BacnetPropertyId.objectName)?.asString);
switch (properties[BacnetPropertyId.presentValue]) {
case BacnetReal(:final value):
print('$value °C');
case BacnetError(:final errorCode):
print('not readable: ${errorCode.label}');
case _:
print('missing or unexpected');
}
Writes encode the datatype of the value class:
await client.writeProperty(1234, BacnetObjectType.binaryOutput, 1,
BacnetPropertyId.presentValue,
const BacnetEnumerated(BacnetBinaryPV.active), priority: 8);
await client.writeProperty(1234, BacnetObjectType.binaryOutput, 1,
BacnetPropertyId.presentValue, const BacnetNull(),
priority: 8); // relinquish
For values whose type is only known at run time (user input,
configuration files) BacnetValue.infer picks the datatype from the object
type, the property and the Dart value, and throws an ArgumentError when
it cannot:
final value = BacnetValue.infer(
double.parse(input),
objectType: BacnetObjectType.analogValue,
propertyId: BacnetPropertyId.presentValue,
); // BacnetReal
Other typed results:
CovNotificationEvent.valuesis aMap<BacnetPropertyId, BacnetValue>withpresentValueandstatusFlagsgetters.PropertyMonitoremits a sealedPropertyUpdate:PropertyValueUpdatewith the value orPropertyErrorUpdatewith theBacnetException.- Trend log entries hold a sealed
TrendLogDatum(TrendLogValue,TrendLogStatus,TrendLogFailure,TrendLogTimeChange) and theBacnetStatusFlagsof the record. readRangetakes a sealedBacnetRange(all,byPosition,bySequenceNumber,byTime).
Values, write specifications and trend logs convert to and from JSON
(toJson, fromJson), e.g. {"datatype": "real", "value": 21.5}.
Typed properties #
BacnetProperties describes the standard properties with their
datatypes, so read returns the Dart type and write only accepts it.
Properties the standard defines as read-only cannot be written: writing
Status_Flags does not compile.
const sensor = BacnetObject(type: BacnetObjectType.analogInput, instance: 1);
final String name = await client.read(1234, sensor, BacnetProperties.objectName);
final units = await client.read(1234, sensor, BacnetProperties.units);
final flags = await client.read(1234, sensor, BacnetProperties.statusFlags);
const output = BacnetObject(type: BacnetObjectType.analogOutput, instance: 1);
await client.write(1234, output, BacnetProperties.analogPresentValue, 21.5,
priority: 8);
final priorities = await client.read(1234, output, BacnetProperties.priorityArray);
print('${priorities.activeValue} @ ${priorities.activePriority}');
The constructed datatypes of schedules, calendars, trend logs and event reporting are classes as well, read and written like any other value:
| Property | Type |
|---|---|
| Weekly_Schedule | BacnetWeeklySchedule (BacnetTimeValues per day) |
| Exception_Schedule | List<BacnetSpecialEvent> |
| Date_List | List<BacnetCalendarEntry> (date, date range, week-n-day) |
| Effective_Period | BacnetDateRange |
| Start_Time, Stop_Time | BacnetDateTime |
| Event_Time_Stamps | List<BacnetTimeStamp> |
| Log_DeviceObjectProperty, List_Of_Object_Property_References | BacnetDeviceObjectPropertyReference |
| Priority_Array | BacnetPriorityArray |
| Event_Enable, Acked_Transitions, Limit_Enable | BacnetEventTransitionBits, BacnetLimitEnable |
| Device_Address_Binding | List<BacnetAddressBinding> |
const schedule = BacnetObject(type: BacnetObjectType.schedule, instance: 1);
final week = await client.read(1234, schedule, BacnetProperties.weeklySchedule);
final days = [for (final day in week.days) List.of(day)];
days[DateTime.monday - 1] = const [
BacnetTimeValue(BacnetTime(hour: 7, minute: 0, second: 0, hundredths: 0),
BacnetReal(21)),
BacnetTimeValue(BacnetTime(hour: 18, minute: 0, second: 0, hundredths: 0),
BacnetNull()),
];
await client.write(1234, schedule, BacnetProperties.weeklySchedule,
BacnetWeeklySchedule(days));
Results of readMultiple have the same typed access:
results[sensor]?.get(BacnetProperties.objectName) (null for errors,
missing properties and other datatypes). Proprietary properties are
declared with BacnetProperty.real(...), BacnetWritableProperty.unsigned(...)
and the other helpers.
Unconfirmed services arrive as typed events too: IHaveEvent (the answer
to sendWhoHas), TextMessageEvent and PrivateTransferEvent.
Alarms and events #
Devices report alarms and events to the recipients of a Notification
Class. subscribeAlarms adds this client (its localAddress()) to the
Recipient_List of a class and returns the subscription:
final alarms = await client.subscribeAlarms(1234, notificationClass: 1);
alarms.notifications.listen((event) async {
print('${event.object}: ${event.fromState?.label} -> ${event.toState.label}');
switch (event.eventValues) {
case BacnetOutOfRangeValues(:final exceedingValue, :final exceededLimit):
print('$exceedingValue is beyond $exceededLimit');
case BacnetChangeOfStateValues(:final newState):
print('new state ${newState.asBinaryPV?.label}');
case final other:
print(other);
}
if (event.ackRequired) {
await client.acknowledgeEvent(event, source: 'operator');
}
});
// ...
await alarms.cancel(); // removes the client from the Recipient_List
Devices without AddListElement get the destination written with
WriteProperty. alarms.refresh() adds it again when a device lost it
(e.g. after a restart). client.eventNotifications delivers the
notifications of all subscriptions; addListElements with
BacnetProperties.recipientList adds any BacnetDestination (another
recipient, a device recipient, confirmed notifications, selected days and
transitions).
The values of every event algorithm are a subclass of the sealed
BacnetEventValues (out of range, change of state, change of value,
buffer ready, change of reliability, ...). getEventInformation lists the
objects in alarm or with unacknowledged transitions, getAlarmSummary the
objects in alarm:
for (final summary in await client.getEventInformation(1234)) {
for (final transition in summary.unacknowledgedTransitions) {
await client.acknowledgeAlarm(1234, summary.object,
summary.stateOf(transition), summary.timeStampOf(transition)!,
source: 'operator');
}
}
addListElement/removeListElement change any list property.
The server reports alarms of Analog and Binary Inputs and Values itself (the event algorithms of bacnet-stack run every second):
await server.addNotificationClass(1, name: 'Alarms');
await server.addObject(BacnetObjectType.analogInput, 1, name: 'Room Temp');
await server.enableEventReporting(
const BacnetObject(type: BacnetObjectType.analogInput, instance: 1),
notificationClass: 1,
highLimit: 26,
lowLimit: 18,
deadband: 0.5,
timeDelay: const Duration(seconds: 30),
);
// clients add themselves to Recipient_List; values beyond the limits are
// reported to them and acknowledged with AcknowledgeAlarm
The server reports what clients change: alarmAcknowledgements (who
acknowledged which alarm) and listElementEvents (recipients added or
removed, e.g. to persist the Recipient_List; bacnet-stack keeps it in
memory only).
Notification Class instances 0..63 are available, with up to 10 recipients each. bacnet-stack keeps one destination per recipient: adding a destination for a recipient that is already in the list replaces it.
An Event Enrollment object monitors a property of another object — local, or remote once the device is bound — instead of enabling the algorithm on the object itself:
await server.addNotificationClass(2, name: 'Range alarms');
await server.addEventEnrollment(
1,
monitored: const BacnetObject(type: BacnetObjectType.analogValue, instance: 7),
notificationClass: 2,
highLimit: 30,
lowLimit: 10,
deadband: 1,
timeDelay: const Duration(seconds: 5),
name: 'Range watch',
);
The enrollment runs the OUT_OF_RANGE algorithm every second and reports to the recipients of its Notification Class. (bacnet-stack ships only Alert Enrollment, so the object is implemented in the native engine.)
client.getEnrollmentSummary lists a device's event-initiating objects — the
intrinsic-reporting objects and Event Enrollments with a configured
Notification Class — with their event type, event state, priority and
notification class, filtered by acknowledgment, event state, event type,
priority range or notification class:
final summary = await client.getEnrollmentSummary(
1234,
notificationClassFilter: 2,
);
for (final entry in summary) {
print('${entry.object} ${entry.eventType} ${entry.eventState}');
}
Auditing #
The server can keep an audit trail of the operations clients perform on it (ASHRAE 135-2016bi). An Audit Reporter records each chosen operation — writes by default — in an Audit Log and, optionally, sends it as an AuditNotification to a recipient:
final log = await server.addAuditLog(1, name: 'Audit log');
await server.addAuditReporter(
1,
auditLog: log,
operations: const [BacnetAuditOperation.write, BacnetAuditOperation.create],
recipient: BacnetRecipient.ip('192.168.1.10', 47808),
);
A client receives those notifications and queries the log:
client.auditNotifications.listen((event) {
final n = event.notification;
print('${n.operation.label} ${n.targetObject} by ${n.sourceDevice}');
});
final result = await client.queryAuditLog(
1234,
const BacnetObject(type: BacnetObjectType.auditLog, instance: 1),
);
for (final record in result.records) print(record);
(bacnet-stack ships the Audit Log object and the record codec; the Audit Reporter object and the AuditNotification and AuditLogQuery services are implemented in the native engine.)
Server state persistence #
A server's container is often ephemeral, so its objects and values can be
snapshotted and restored. The snapshot is stored through a pluggable
BacnetServerStateStore; JsonFileServerStateStore writes a JSON file (atomic
replace), and you can implement the interface for any other backend.
final store = JsonFileServerStateStore(File('/data/server-state.json'));
// on start: re-create the saved objects and values, if any
await server.start();
await server.init(4194300, 'Controller');
await server.restoreState(store);
// later, after configuration or value changes (or on shutdown):
await server.saveState(store);
captureState records the objects added with addObject (and its helpers)
with their identity, name, description and present value; re-apply richer
per-object configuration (units, limits, schedules, recipients) from the
application after restoreState.
Device management and files #
// stop a device from initiating requests for 30 minutes
await client.deviceCommunicationControl(
1234,
BacnetCommunicationState.disableInitiation,
duration: const Duration(minutes: 30),
password: 'secret',
);
await client.reinitializeDevice(1234, BacnetReinitializedState.warmStart,
password: 'secret');
// objects
final setpoint = await client.createObject(1234,
type: BacnetObjectType.analogValue,
initialValues: const [
BacnetPropertyValue(
propertyIdentifier: BacnetPropertyId.presentValue,
value: BacnetReal(21),
),
]);
await client.deleteObject(1234, setpoint);
// files: whole files in chunks that fit into one APDU of the device
final backup = await client.readFile(1234, 1,
onProgress: (done, total) => print('$done / $total'));
await client.writeFile(1234, 1, backup);
// vendor services and messages
final result = await client.privateTransfer(1234, 260, 7,
parameters: const BacnetUnsigned(5));
await client.sendTextMessage('Shutdown at 18:00'); // broadcast
When a device rejects an initial value of createObject (or an element
of addListElement), BacnetProtocolException.firstFailedElement names
its position. readFileStream/writeFileStream and
readFileRecords/writeFileRecords access parts of files.
The server answers DeviceCommunicationControl and ReinitializeDevice only
with the password given to init (none: it refuses both) and reports
accepted requests; restarting is up to the application:
await server.init(4194300, 'Controller', password: 'secret');
server.reinitializeRequests.listen((request) {
if (request.state == BacnetReinitializedState.warmStart) restart();
});
server.communicationControls.listen(print);
The server hosts File objects whose content it keeps in memory: clients read them with AtomicReadFile and, unless they are read only, write them with AtomicWriteFile (appends answer with the position they wrote at) and truncate them by writing File_Size. Modification_Date and Archive follow the changes:
await server.addFile(1,
name: 'settings.json',
fileType: 'application/json',
content: utf8.encode(jsonEncode(settings)));
await server.addFile(2, name: 'firmware.bin', readOnly: true,
content: firmware);
server.fileWrites.listen((write) async {
print('${write.data.length} octets at ${write.start} of file '
'${write.instance}');
final content = await server.fileContent(write.instance);
applySettings(jsonDecode(utf8.decode(content)));
});
await server.setFileContent(1, utf8.encode(jsonEncode(defaults)));
The typed descriptors BacnetProperties.fileType, fileSize,
modificationDate, archive, readOnly and fileAccessMethod read the
properties of File objects of any device.
Schedules, trend logs and backups #
The server runs schedules, keeps trend logs and lets clients back it up:
// heating setpoint: 21 °C on workdays from 7:00 to 18:00, else 17 °C
const workday = [
BacnetTimeValue(
BacnetTime(hour: 7, minute: 0, second: 0, hundredths: 0),
BacnetReal(21),
),
BacnetTimeValue(
BacnetTime(hour: 18, minute: 0, second: 0, hundredths: 0),
BacnetReal(17),
),
];
await server.addCalendar(1, name: 'Holidays', dates: [
BacnetCalendarDate(BacnetDate(year: 2026, month: 12, day: 25)),
]);
await server.addSchedule(
1,
name: 'Heating',
scheduleDefault: const BacnetReal(17),
weeklySchedule: BacnetWeeklySchedule([
for (var day = 0; day < 5; day++) workday,
const [],
const [],
]),
exceptionSchedule: [
BacnetSpecialEvent(
period: const BacnetCalendarReference(
BacnetObject(type: BacnetObjectType.calendar, instance: 1),
),
timeValues: const [
BacnetTimeValue(
BacnetTime(hour: 0, minute: 0, second: 0, hundredths: 0),
BacnetReal(17),
),
],
priority: 1,
),
],
references: const [
BacnetDeviceObjectPropertyReference(
object: BacnetObject(type: BacnetObjectType.analogValue, instance: 1),
property: BacnetPropertyId.presentValue,
),
],
priorityForWriting: 12,
);
// writes of the schedule arrive with `internal` set
server.writeEvents.where((write) => write.internal).listen(print);
// the setpoint every 5 minutes, the last 10 000 records
await server.addTrendLog(1,
name: 'Setpoint log',
source: const BacnetDeviceObjectPropertyReference(
object: BacnetObject(type: BacnetObjectType.analogValue, instance: 1),
property: BacnetPropertyId.presentValue,
),
logInterval: const Duration(minutes: 5),
bufferSize: 10000);
// or values of the application
await server.addTrendLog(2, name: 'Meter readings');
await server.logValue(2, const BacnetReal(1234.5));
Clients read the logs with ReadRange (client.getTrendLog,
client.readRange) and change schedules, calendars and logs with
WriteProperty.
Backup and restore (ASHRAE 135 clause 19.1) copies the configuration files
of a device; the backup is a BacnetDeviceBackup that stores as JSON:
final backup = await client.backupDevice(1234, password: 'secret',
onProgress: (done, total) => print('$done / $total files'));
await File('ahu-1.json').writeAsString(jsonEncode(backup.toJson()));
// ... later, or to a replaced controller of the same kind:
await client.restoreDevice(1234, backup, password: 'secret');
The server takes part with the File objects that hold the configuration of the application:
await server.addFile(1, name: 'settings.json');
await server.enableBackup(
files: [1],
prepareBackup: () =>
server.setFileContent(1, utf8.encode(jsonEncode(settings))),
applyRestore: () async {
settings = jsonDecode(utf8.decode(await server.fileContent(1)));
},
);
The server can also act as a Time Master (ASHRAE 135 clause 13.12), sending TimeSynchronization or UTCTimeSynchronization to its recipients at an interval:
// broadcast UTC time every hour, aligned to the top of the hour
await server.enableTimeMaster(
interval: const Duration(hours: 1),
utc: true,
alignToClock: true,
);
// or to specific recipients (a device resolved by Who-Is, an address)
await server.enableTimeMaster(
interval: const Duration(minutes: 10),
recipients: [
BacnetRecipient.device(1234),
BacnetRecipient.ip('192.168.1.50', 47808),
],
);
await server.disableTimeMaster();
Lighting and color #
Lighting Output, Binary Lighting Output, Color and Color Temperature objects
are driven with the typed BacnetLightingCommand and BacnetColorCommand
values, written to Lighting_Command and Color_Command:
// host the objects on a server
await server.addLightingOutput(1, name: 'Desk lamp');
await server.addColorTemperature(1, name: 'Tunable white');
// a client fades a lamp to 80 % over two seconds
await client.write(1234, lamp, BacnetProperties.lightingCommand,
const BacnetLightingCommand(
operation: BacnetLightingOperation.fadeTo,
targetLevel: 80,
fadeTime: Duration(seconds: 2),
));
// and warms a tunable-white fitting to 2700 K
await client.write(1234, white, BacnetProperties.colorCommand,
const BacnetColorCommand(
operation: BacnetColorOperation.fadeToColorTemperature,
targetColorTemperature: 2700,
fadeTime: Duration(seconds: 1),
));
final command =
await client.read(1234, lamp, BacnetProperties.lightingCommand);
print(command.operation.label); // Fade To
Provisioning, groups and COV of several properties #
New devices without a configured device instance ask a supervisor for one with Who-Am-I, identifying themselves by vendor, model name and serial number; the supervisor answers with You-Are:
// supervisor
client.whoAmIRequests.listen((request) async {
final instance = inventory[request.serialNumber];
if (instance == null) return;
await client.sendYouAre(
vendorId: request.vendorId,
modelName: request.modelName,
serialNumber: request.serialNumber,
deviceId: instance,
destination: request.source, // or leave out to broadcast
);
});
// device
await server.init(4194302, 'Room controller',
vendorId: 260, modelName: 'RC-1', serialNumber: 'SN-0042');
final instance = await server.requestDeviceInstance(); // null: no answer
server.setDeviceInstance changes the instance directly. Channel objects
group the properties one value controls (all lights of a floor, all
setpoints of a zone); WriteGroup writes the channels of a control group
of every device with one broadcast:
await server.addChannel(1,
name: 'Lights floor 2',
channelNumber: 7,
controlGroups: [5],
members: const [
BacnetDeviceObjectPropertyReference(
object: BacnetObject(type: BacnetObjectType.analogOutput, instance: 1),
property: BacnetPropertyId.presentValue,
),
]);
// any client: 80 % on channel 7 of group 5, at priority 10
await client.writeGroup(5, [BacnetGroupChannelValue(7, const BacnetReal(80))],
writePriority: 10);
The members of a channel are written at the priority of the request and
arrive as writeEvents with internal set; the requests as
writeGroupEvents.
subscribeCOVPropertyMultiple subscribes to several properties of several
objects with one request, each with its own COV increment and optionally
with the time of each change. The notifications arrive on covEvents,
one CovNotificationEvent per object:
await client.subscribeCOVPropertyMultiple(1234, [
BacnetCovSubscriptionSpecification(supplyTemp, const [
BacnetCovReference(BacnetPropertyId.presentValue, covIncrement: 0.2),
BacnetCovReference(BacnetPropertyId.statusFlags),
]),
BacnetCovSubscriptionSpecification(fan, const [
BacnetCovReference(BacnetPropertyId.presentValue, timestamped: true),
]),
], processId: 7, lifetime: const Duration(minutes: 10));
client.covEvents.listen((event) {
print('${event.object}: ${event.presentValue} '
'changed at ${event.changeTimes[BacnetPropertyId.presentValue]}');
});
Devices without the service reject it (BacnetRejectException); use
subscribeCOV for them. bacnet-stack, and so the server of this package,
does not implement it.
Device descriptions and the command line tool #
describeDevice reads every property of every object of a device, with
ReadPropertyMultiple ALL where the device has it, property by property
otherwise. The BacnetDeviceDescription stores as JSON, e.g. to compare a
controller with its state at commissioning, and lists the objects in the
notation of an EPICS:
final description = await client.describeDevice(
1234,
onProgress: (done, total) => print('$done / $total objects'),
);
await File('ahu-1.json').writeAsString(jsonEncode(description.toJson()));
print(description.toEpics());
// {
// {
// object-identifier: (analog-input, 1)
// object-name: "Supply air temperature"
// present-value: 21.5
// units: degrees-celsius
// ...
BacnetProperties.protocolServicesSupported tells which services a device
executes:
final services = await client.read(1234, device,
BacnetProperties.protocolServicesSupported);
if (!services.contains(BacnetServiceSupported.readPropertyMultiple)) ...
The package has a command line tool for the same tasks:
dart run bacnet_plugin:bacnet discover
dart run bacnet_plugin:bacnet read 1234 ai:1 present-value
dart run bacnet_plugin:bacnet write 1234 av:1 present-value 21.5 --priority 8
dart run bacnet_plugin:bacnet watch 1234 ai:1
dart run bacnet_plugin:bacnet --json describe 1234 --output ahu-1.json
dart run bacnet_plugin:bacnet -a 192.168.1.20 objects 1234 # without Who-Is
dart run bacnet_plugin:bacnet --help
Objects are written as type:instance (analog-input:1, ai:1, 0:1),
properties by name or number. Values are typed for the property (REAL for
analog present values, ENUMERATED active/inactive for binary ones,
...) or explicitly: real:21.5, unsigned:3, enum:1, string:text.
Routers, networks and BBMDs #
Routers connect BACnet networks (BACnet/IP subnets, MS/TP trunks). Their
network layer messages arrive as NetworkMessageEvents on
client.networkMessages; BacnetNetworkDiscovery finds routers, the
number of the local network and routing tables:
for (final router in await client.discoverRouters()) {
print('${router.ipAddress}:${router.port} routes to ${router.networks}');
}
final number = await client.whatIsNetworkNumber(); // null: nobody knows
final table = await client.readRoutingTable('192.168.1.1');
client.networkMessages.listen((event) {
switch (event.message) {
case BacnetRejectMessageToNetwork(:final reason, :final network):
print('network $network unreachable: ${reason.label}');
case BacnetRouterBusyToNetwork(:final networks):
print('router ${event.ipAddress} busy for $networks');
case _:
}
});
sendNetworkMessage sends any network layer message
(BacnetNetworkMessage subclasses, proprietary ones as
BacnetOtherNetworkMessage) as a local broadcast, to a router, or to a
device behind it. Devices behind a router are reached with
addDeviceBinding(..., network: 5, adr: [...]) or by their I-Am.
BBMDs forward broadcasts between BACnet/IP subnets. BacnetBbmdClient
reads and changes their tables over its own UDP socket, without a started
client:
final bbmd = BacnetBbmdClient('192.168.1.1');
final peers = await bbmd.readBroadcastDistributionTable();
final foreignDevices = await bbmd.readForeignDeviceTable();
await bbmd.writeBroadcastDistributionTable([
...peers,
BacnetBdtEntry('10.0.0.1'), // forward broadcasts to this BBMD too
]);
await bbmd.deleteForeignDeviceTableEntry('192.168.5.20');
The server can also present itself as a router to one or more virtual networks, so other devices discover it with Who-Is-Router-To-Network:
await server.enableRouting([100, 200]);
// elsewhere: client.discoverRouters() now lists this server for 100 and 200
It answers Who-Is-Router-To-Network and Initialize-Routing-Table for those networks; forwarding APDUs to devices behind the router is not implemented.
BACnet/IPv6 #
On POSIX systems the client and server can run over the BACnet/IPv6 datalink (ASHRAE 135 ANNEX U) instead of BACnet/IPv4:
final server = BacnetServer(
config: const BacnetConfig(interface: 'eth0', useIPv6: true),
);
The named interface must have an IPv6 address. IPv6 is not available on Windows, and the engine's test container has no IPv6 loopback, so the end-to-end IPv6 test skips where IPv6 is unavailable.
A device that is not a BBMD, or refuses a change, answers with a NAK:
BacnetBbmdException.result tells which (BacnetBvlcResult).
client.registerForeignDevice registers the client itself with a BBMD.
Testing your application #
package:bacnet_plugin/testing.dart provides FakeBacnetClient, a
BacnetClient backed by in-memory devices. Code using the client,
DeviceScanner or PropertyMonitor runs unchanged in unit and widget tests,
without a network or the native stack:
import 'package:bacnet_plugin/bacnet_plugin.dart';
import 'package:bacnet_plugin/testing.dart';
final ahu = FakeBacnetDevice(1234, name: 'AHU-1')
..addObject(
BacnetObjectType.analogInput,
1,
presentValue: const BacnetReal(21.5),
units: BacnetEngineeringUnits.degreesCelsius,
)
..addObject(BacnetObjectType.analogOutput, 1);
final client = FakeBacnetClient(devices: [ahu]);
await client.start();
// discovery, reads, writes with priorities and COV work as with a device
final devices = await DeviceScanner(client).discoverDevices(
timeout: const Duration(milliseconds: 50),
);
// simulate the field and the network
ahu.object(BacnetObjectType.analogInput, 1)![BacnetPropertyId.presentValue] =
const BacnetReal(23); // COV notification to subscribers
ahu.online = false; // requests time out
client.latency = const Duration(milliseconds: 200);
// assert what the application sent
expect(client.requests.where((r) => r.service == 'writeProperty'), isEmpty);
Routers of the simulated network answer router discovery, What-Is-Network-Number and routing table queries:
final client = FakeBacnetClient(
devices: [ahu],
routers: [FakeBacnetRouter('10.0.0.1', networks: [5, 6])],
networkNumber: 1,
);
Fake devices report alarms to the clients in the Recipient_List of a
Notification Class, keep the event state for getEventInformation and
getAlarmSummary and check acknowledgements like a device.
device.unsupportedServices makes a device reject services, to test the
fallbacks of an application:
ahu.addNotificationClass(1);
final sensor = ahu.object(BacnetObjectType.analogInput, 1)!
..enableEventReporting(notificationClass: 1);
// after the application added itself to Recipient_List:
sensor.reportEvent(
BacnetEventState.highLimit,
eventValues: const BacnetOutOfRangeValues(
exceedingValue: 31,
statusFlags: BacnetStatusFlags(inAlarm: true),
deadband: 1,
exceededLimit: 30,
),
);
Migrating from 0.7.x #
- Remote clients grow File objects of the server up to
maxSize(16 MiB by default,addFile/configureFile); larger AtomicWriteFile and File_Size writes fail. Before, the limit was 2 GiB per file. PropertyMonitorreads Protocol_Services_Supported of each device once and uses SubscribeCOVPropertyMultiple where the device has it. PassuseCovMultiple: falseto subscribe every property on its own as before; mocks ofBacnetClientused with a monitor need an answer for that read (anyBacnetExceptionmeans "not supported").- Fake devices answer Protocol_Services_Supported and ReadPropertyMultiple with ALL instead of an unknown property error.
Migrating from 0.6.x #
WhoAmIEvent,YouAreEventandWriteGroupEventare newBacnetEventsubclasses: aswitchoverBacnetEventneeds cases for them.- Classes implementing
BacnetClientneedwhoAmIRequests,sendYouAre,writeGroup,subscribeCOVPropertyMultipleandunsubscribeCOVPropertyMultiple. - The client acknowledges ConfirmedCOVNotificationMultiple; it rejected them as an unrecognized service before.
Migrating from 0.5.x #
- Schedules of the server write their members through the server like
clients do: those writes arrive as
writeEventswithPropertyWriteEvent.internalset. - Fake devices refuse the backup and restore states of
reinitializeDeviceunless they haveconfigurationFiles, like devices without the procedure.
Migrating from 0.4.x #
- The server refuses DeviceCommunicationControl and ReinitializeDevice
unless
init(orsetPassword) sets a password. Before, it accepted bacnet-stack's default password "filister". CommunicationControlEvent,ReinitializeDeviceEvent,NetworkMessageEventandFileWriteEventare newBacnetEventsubclasses: aswitchoverBacnetEventneeds cases for them.- Classes implementing
BacnetClientneed the new device management, file and messaging methods,deviceMaxApdu,networkMessagesandsendNetworkMessage. sendWhoIs(network: 0)(and other local broadcasts) is sent as a broadcast (Original-Broadcast-NPDU, or Distribute-Broadcast-To-Network as registered foreign device). Before, it went as a unicast to the broadcast address, which BBMDs did not forward.
Migrating from 0.3.x #
AlarmAcknowledgedEventandListElementEventare newBacnetEventsubclasses: aswitchoverBacnetEventneeds cases for them.BacnetEventSummary.isUnacknowledgedcovers every transition; useunacknowledgedTransitionswithtimeStampOfandstateOfto acknowledge them.- Classes implementing
BacnetClientneedlocalAddress().
Migrating from 0.2.x #
- Event notifications arrive as
EventNotificationEventinstead ofUnconfirmedServiceEvent. AswitchoverBacnetEventneeds a case for it. FakeBacnetClientimplements the new client methods; classes that implementBacnetClientthemselves need them too.
Migrating from 0.1.x #
- Object types, property identifiers and other enumerations are typed:
replace numbers such as
readProperty(id, 2, 1, 85)with constants (BacnetObjectType.analogValue,BacnetPropertyId.presentValue) orBacnetObjectType(2).getName(value)still works; prefer.label. - Values are typed (see Values):
readPropertyreturns aBacnetValueinstead ofdynamic;writeProperty,BacnetPropertyValueand the server'ssetPresentValue,setProperty,addObjectandBacnetPresentValueUpdatetake one. Thetag:parameters are gone: writeconst BacnetEnumerated(1)instead of1, tag: BacnetApplicationTag.enumerated, and useBacnetValue.inferfor input whose type is only known at run time.nullbecomesconst BacnetNull(). readMultipleandDeviceScanner.scanDevicereturnMap<BacnetObject, Map<BacnetPropertyId, BacnetPropertyResult>>instead of maps keyed by'type:instance'strings andints:results[object]?.valueOf(BacnetPropertyId.presentValue).BacnetObjectis only an identifier (it no longer hasproperties) and is the ObjectIdentifier value.PropertyUpdateis sealed (PropertyValueUpdate/PropertyErrorUpdate);TrendLogEntryhas adatumandstatusFlagsinstead ofvalueand astatusstring;readRangetakes aBacnetRangeinstead oftype/reference/count.scanDevice(endDeviceId:)is removed; passbackground/cancelToken.
Migrating from 0.0.x #
- The package is a pure Dart package with a build hook: remove platform specific setup; Dart 3.11/Flutter 3.44 are required.
readPropertyreturnsBacnetObjectinstead of{'type', 'instance'}maps for object identifiers and complete lists for array properties.writeProperty(tag:)defaults to datatype inference instead of REAL.BacnetClient.eventsis aStream<BacnetEvent>; useiAmEventsandcovEventsfor typed streams. COV notifications now include the initiating device and the values.subscribeCOVcompletes when the device acknowledged the subscription; passprocessId/lifetimeand cancel withunsubscribeCOV.PropertyWriteEvent.valueis decoded; the raw bytes are inrawValue.- Internal request/response classes (
WhoIsRequest,ReadPropertyRequest, ...) were removed from the public API. dispose()releases the shared stack; preferawait close().
License #
The plugin is MIT licensed. It bundles bacnet-stack, licensed
GPL-2.0-or-later WITH GCC-exception-2.0, which allows linking it into
proprietary applications; changes to bacnet-stack itself must be published.
See native/bacnet-stack/license.