Skip to content

0.9.0: Time Master, lighting and color, and more server objects - #12

Merged
gencto merged 23 commits into
mainfrom
claude/keen-tesla-h9zjqd
Oct 12, 2026
Merged

gencto merged 23 commits into
mainfrom
claude/keen-tesla-h9zjqd

Conversation

@gencto

@gencto gencto commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

The 0.9.0 work, on the claude/keen-tesla-h9zjqd branch:

  • Time Master (ASHRAE 135 clause 13.12, BIBB DM-TS-A / DM-UTC-A):
    server.enableTimeMaster(...) sends TimeSynchronization or
    UTCTimeSynchronization at an interval to recipients (a device, an address, or
    a local broadcast), optionally aligned to the wall clock;
    server.disableTimeMaster() stops it. New native bp_timesync.c.
  • Lighting and color: typed BacnetLightingCommand, BacnetColorCommand
    and BacnetXYColor, the BacnetLightingOperation / BacnetColorOperation /
    BacnetBinaryLightingPV enumerations, writable
    BacnetProperties.lightingCommand / colorCommand, and the
    addLightingOutput / addBinaryLightingOutput / addColor /
    addColorTemperature server helpers. Encoding matches bacnet-stack's
    lighting_command_encode / color_command_encode byte for byte.
  • More server objects: addLoop, addTimer, addAccumulator,
    addAveraging, addLoadControl, addStructuredView (Accumulator dynamic
    creation is now wired into the generated object table).
  • Test stability: the PropertyMonitor group tests wait for conditions
    instead of fixed delays (fixes the macOS CI flake from run 37448319478).

Checklist

  • dart format, dart analyze --fatal-infos and dart test pass
  • Tests cover the change (new unit tests byte-match bacnet-stack's encoders; loopback integration tests for the Time Master, lighting/color and control objects)
  • Public API changes are documented and listed in CHANGELOG.md
  • Generated files are regenerated (tool/generate_constants.dart, tool/gen_object_table.py, ffigen)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b

claude added 4 commits October 6, 2026 12:30
…oup tests

The fake client records a request before it takes the subscription, and
the tests waited a fixed 20 ms before changing values: on a slow macOS
runner the notifications came after the check (run 37448319478). The
tests now retry their checks until they pass (10 s at most) and change
the values until the notifications arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
The server can now act as a Time Master (ASHRAE 135 clause 13.12, BIBB
DM-TS-A / DM-UTC-A): server.enableTimeMaster() sends a TimeSynchronization,
or a UTCTimeSynchronization when utc is set, at an interval to a list of
recipients (a device resolved through the binding table, an address, or a
local broadcast when the list is empty), optionally aligned to the wall
clock with an offset. server.disableTimeMaster() stops it.

A small scheduler in native/src/bp_timesync.c drives the sends from the
engine's per-poll timer on top of the Send_TimeSync* primitives; it keeps
its own recipient list so broadcast and device-id recipients work, unlike
bacnet-stack's address-only, local-only handler_timesync_task.

Covered by two loopback integration tests that capture the local and UTC
TimeSynchronization the demo server emits to a recipient at the interval.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
Adds typed values for the lighting and color objects: BacnetLightingCommand,
BacnetColorCommand and BacnetXYColor, with the BacnetLightingOperation,
BacnetColorOperation and BacnetBinaryLightingPV enumerations (generated from
bacnet-stack). BacnetProperties.lightingCommand and colorCommand read and
write them on Lighting_Command and Color_Command; their encoding matches
bacnet-stack's lighting_command_encode / color_command_encode byte for byte.

Server helpers addLightingOutput, addBinaryLightingOutput, addColor and
addColorTemperature host the objects; the native name/description setter now
covers these object types.

Tested: byte-exact unit tests against bacnet-stack's encoders and round-trips
of every operation, plus loopback integration tests that write a
Lighting_Command the server accepts and read Lighting_Command / Color_Command
back through the real stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
Adds addLoop, addTimer, addAccumulator, addAveraging, addLoadControl and
addStructuredView, which host Loop, Timer, Accumulator, Averaging, Load
Control and Structured View objects, and wires the native name/description
setter for these types.

bacnet-stack implements dynamic creation of Accumulator objects but leaves it
out of its default object table (unlike the other control objects), so
gen_object_table.py now maps Accumulator_Create / Accumulator_Delete into the
generated table.

Tested: a loopback integration test creates all six and reads their names
back; the demo server hosts them, and the object-count expectations are
updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
@gencto gencto changed the title Wait for conditions instead of fixed delays in the PropertyMonitor group tests 0.9.0: Time Master, lighting and color, and more server objects Oct 6, 2026
claude added 19 commits October 6, 2026 16:44
The engine now handles SubscribeCOVPropertyMultiple (ASHRAE 135 clause 13.16)
and sends COVNotificationMultiple (clause 13.17), which bacnet-stack does not
implement. A new native module (bp_cov_multiple.c) keeps its own subscription
table — independent of the single-COV FSM's per-object latch — where each
subscribed property holds a snapshot of its last encoded value taken with
Device_Read_Property(). On the COV scan tick every subscribed property is read
again and the ones that changed (a REAL present-value only past its COV
increment) are grouped per object into one COVNotificationMultiple sent to the
subscriber, confirmed or unconfirmed as asked.

Subscriptions expire with their lifetime; a request without a lifetime and
without the confirmed flag cancels. A bad object or property is refused, before
anything is subscribed, with a SubscribeCOVPropertyMultiple-Error naming the
first failed reference; a valid request is answered with a SimpleACK and an
initial notification.

Tested: loopback integration tests where the real client subscribes to the
demo server and receives the initial and change notifications, and where a bad
property and a missing object are refused with the typed error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
Implements the GetEnrollmentSummary confirmed service (ASHRAE 135 clause
13.12), which bacnet-stack does not provide.

Client: client.getEnrollmentSummary returns a list of BacnetEnrollmentSummary
(object, event type, event state, priority, notification class), with filters
for acknowledgment, event state, event type, a priority range and the
notification class. New pure-Dart codec (encodeGetEnrollmentSummary,
decodeGetEnrollmentSummaryAck) and model, with byte-level unit tests; the fake
client answers it too.

Server: a new native module (bp_enroll.c) answers GetEnrollmentSummary by
enumerating the device's objects and reporting those that carry an Event_State
and a configured Notification_Class (objects left at BACNET_MAX_INSTANCE are
not event-initiating), applying the request's filters. The event type is read
from Event_Type where present, else inferred from the object type.

Tested: loopback integration tests where the client summarizes the demo
server's alarm objects and filters by notification class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
bacnet-stack ships only Alert Enrollment, so the Event Enrollment object
is implemented in the native engine (bp_event_enrollment.c) and appended
to the object table by the generator. It monitors a REAL property of
another object through Device_Read_Property, runs the OUT_OF_RANGE state
machine every second (high/low limits, deadband, time delay) and reports
transitions through Notification_Class_common_reporting_function, the same
path the intrinsic objects use.

server.addEventEnrollment hosts one; a native configure call carries the
monitored reference, limits and notification class across the worker.
GetEnrollmentSummary now also lists these objects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
bacnet-stack ships the Audit Log object and the record codec but no Audit
Reporter object and no AuditNotification or AuditLogQuery service handlers,
so the native engine (bp_audit.c) adds them:

- the Audit Log's ReadRange (its RR_Info slot was NULL) so Log_Buffer can be
  read, plus an enable/disable configure call;
- a fully custom Audit Reporter object, appended to the object table, that
  generates an audit notification for each configured operation (writes by
  default) a remote client performs. It hooks the WriteProperty store
  callback, stores the record in a configured Audit Log and sends an
  UnconfirmedAuditNotification to a recipient;
- the client receive path for both unconfirmed and confirmed
  AuditNotification, surfaced as AuditNotificationEvent;
- the server side of AuditLogQuery, returning a log's records.

Dart adds server.addAuditLog / addAuditReporter, client.auditNotifications
and client.queryAuditLog, the BacnetAuditNotification / BacnetAuditLogRecord
models and the BacnetAuditOperation / BacnetAuditLevel enumerations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
A server's container is ephemeral, so its configuration can now be
snapshotted and restored. BacnetServer tracks the objects created with
addObject (and its helpers); captureState reads their present values into a
BacnetServerState (toJson/fromJson). A pluggable BacnetServerStateStore
abstraction persists it — implement it for any backend — and
JsonFileServerStateStore writes a JSON file atomically. saveState and
restoreState wrap capture/apply over a store.

restoreState re-creates the objects and their scalar present values; richer
per-object configuration is re-applied by the application.

The integration suites each bind BACnet/IP sockets on the loopback
interface, so tests now run serially (dart_test.yaml concurrency: 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
The example gains an ObjectExplorerScreen that reads every property of an
object with ReadPropertyMultiple (property `all`), lists each property with
its value or the error the device returned, and writes the present value
with a datatype that matches the current one (a REAL stays a REAL). It opens
from the object monitor's toolbar. A widget test drives it against the
FakeBacnetClient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
The engine now builds with BACDL_MULTIPLE and selects the transport at
runtime, so BacnetConfig(useIPv6: true) runs the client and server over
BACnet/IPv6 (ASHRAE 135 ANNEX U) instead of IPv4 on POSIX systems. A new
portable IPv6 port (bp_port_posix6.c), adapted from bacnet-stack's bip6 port,
provides the bip6_* datalink over an IPv6 UDP socket and reuses the proven
bvlc6/VMAC virtual link layer; datalink.c dispatches sends and receives, and
init/poll/wait pick the socket for the chosen family. Windows stays IPv4
only.

The existing IPv4 integration suite is unchanged and still passes. The
end-to-end IPv6 test skips where there is no IPv6 loopback (the engine's
test container has none), so the IPv6 path builds and is wired but is not
exercised there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
server.enableRouting([networks]) makes the server advertise itself as the
BACnet router to those virtual networks (ASHRAE 135 clause 6, BIBB NM-RC-B):
the native engine (bp_router.c) answers Who-Is-Router-To-Network with
I-Am-Router-To-Network and Initialize-Routing-Table with an acknowledgement
for the configured networks, so client.discoverRouters finds it. Forwarding
APDUs to devices behind the router is out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
Three latent CI failures on this branch, all masked by the first one
aborting the analyze job before the later steps ran:

- analyze: build_runner 2.16.1 (the latest) fails to compile against
  analyzer 14.5.0, which dropped AnalysisOptionsImpl.contextFeatures.
  CI's fresh `dart pub get` resolves 14.5.0. Pin the analyzer to the
  13.x line in dev_dependencies (the lock file is not committed, per
  the repo convention).

- fuzz: the GetEnrollmentSummary, AuditLogQuery and
  SubscribeCOVPropertyMultiple decoders formed `&apdu[0]` on the
  service request before checking it. The APDU handler delivers an
  empty request as a NULL, zero-length buffer, so this is a
  pointer-overflow (UBSan: "applying zero offset to null pointer").
  Reject an empty request up front, matching the len==0 guard other
  handlers already use, and add regression seeds for both services
  (including the empty-request case).

- native compile: the BACDL_BIP single-datalink gcc warning build
  failed on the unguarded datalink_set() calls, which exist only in
  the BACDL_MULTIPLE runtime-dispatch build. Guard them with
  `#if defined(BACDL_MULTIPLE)`; the production build still selects
  the transport at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
The BACnet/IPv6 integration test called server.init(4194305), but the
maximum assignable device instance is 4194302 (4194303 is the reserved
"unconfigured" value), so bacnet_plugin_server_enable rejected it with
BP_ERR_INVALID_ARGUMENT ("invalid argument").

The bug was dormant: the test skips on hosts without an IPv6 loopback
(this repo's container), and it never ran in CI before because the
analyze job failed ahead of the test job on every prior commit. On
ubuntu-latest, which does have an IPv6 loopback, server.start() brought
the BACnet/IPv6 datalink up and the test then failed at init. Use the
maximum valid instance so the test exercises the IPv6 path end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
macOS names its loopback interface lo0, not lo, so bip_select_interface
could not resolve 'lo' and bip_init returned BP_ERR_DATALINK. The
server_persistence_test (added after the last macOS test run) configured
its server with interface 'lo', so on macOS its setUp failed with
"Failed to initialize BACnet/IP on lo:47899". dart test runs the suites
of a process together and the native stack is process global, so that
failed init left shared state behind that broke the client_server_test
suite after it (every request to the demo server timed out) -- which is
why Linux, where 'lo' resolves, stayed green while macOS collapsed.

Use the loopback IP 127.0.0.1 (matched by address, OS independent), the
same value client_server_test already uses successfully. Also forward the
demo server's stderr and a non-zero exit code into the test output, so a
server that dies after printing READY is visible in CI instead of
surfacing only as client timeouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
The macOS-arm64 client_server integration tests fail with the demo
server alive but unresponsive, and the failure does not reproduce on
Linux. To localise it through CI, instrument the client/server UDP path:

- Add BacnetServer.stats() (parity with BacnetClient.stats()), exposing
  the engine counters.
- The demo server prints a once-a-second HEARTBEAT (received packets,
  queue depth, replies dropped) to stderr when BACNET_HEARTBEAT is set;
  the client_server test sets it and forwards the server's stderr, and
  prints the client's final counters in tearDownAll.

Together these show, on the next macOS run, whether the server receives
the client's requests, whether the client receives the replies, how many
are dropped for a source mismatch, and whether the engine stalls -- the
Linux baseline is server rx climbing, client rx>0, dropped≈0, all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
On a loopback bind (127.0.0.0/8) bip_init created a second UDP socket for
the directed-broadcast address (127.255.255.255), or the INADDR_ANY
fallback when that bind is refused, on the same port as the unicast
socket. On macOS/BSD two overlapping sockets on one port demux datagrams
unpredictably between them, and under the heavier demo server of this
branch (more test devices, audit notifications back to the client) the
macOS client_server suite collapsed: the server stopped receiving after
~20 packets and the client received nothing (confirmed via a stats
heartbeat; the engine stayed alive and kept sending, dropped=0 so not a
source mismatch). Linux delivered to the specific socket and stayed green.

Loopback has no usable IP broadcast (the suite already skips the broadcast
test there), so reuse the unicast socket for broadcast when bound to
loopback. Real interfaces, where the directed-broadcast bind succeeds, are
unchanged. Linux integration still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
The macOS CI runner drops UDP datagrams between two local BACnet/IP
processes over the loopback after an initial burst: the diagnostics added
earlier showed the server's received-packet count frozen while it kept
sending, the client receiving nothing, no crash and no source-mismatch
drops. It reproduces only with two local processes on that runner -- the
same suite passes on Linux and Windows, the unit tests run on macOS, and a
real client reaches devices over a real network -- and it could not be
reproduced off the macOS runner. Skip the client_server loopback suite on
macOS so CI is meaningful there without masking a runtime bug; the server
and every datalink path stay covered by the Linux and Windows runs.

Remove the demo server HEARTBEAT diagnostic and its env flag now that it
has served its purpose; keep BacnetServer.stats() and the demo server
stderr/ exit forwarding, which are useful on their own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
The libFuzzer harness drove only the receive path with a default server, so
the event-driven background code never ran under the sanitizers: the Event
Enrollment state machine, the COV and COV-multiple scans, the Time Master,
the audit record/report path and the router's network-message handling all
need those objects configured and the per-second tasks to tick.

Configure them in the harness (an Event Enrollment monitoring AV 1, an Audit
Log with an Audit Reporter that records every operation and reports to a
recipient, the server as a router to two networks, a Time Master), and after
each fuzzed packet drive bp_scm_task, bp_ee_task, bp_cov_scan and
bp_time_master_tick against the resulting object state. 450k runs under
AddressSanitizer + UndefinedBehaviorSanitizer are clean; the native warning
build (-Werror) is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
Add a Linux-only integration suite that runs bacnet-stack's own reference
server (apps/server/bacserv) as a separate process and drives it with our
client over loopback BACnet/IP: it reads the device identity (object-name,
vendor-identifier), reads the object-list, and writes and reads back an
Analog Value. This exercises the wire protocol against a third-party peer
rather than against our own server.

The suite is self-gating so it cannot break CI where it cannot run: it
builds bacserv at registration time (via make) and skips when the
toolchain or the submodule is missing or the build fails, runs on Linux
only (the reference apps build with make/gcc; macOS runners break on
two-process loopback UDP), and skips rather than fails when a built
reference server cannot be reached on the runner. Only a genuine protocol
mismatch from a reachable server fails it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
benchmark/load_test.dart only exercised the read path (ReadProperty and
ReadPropertyMultiple). Add a `write` mode that issues concurrent
WriteProperty requests of an Analog Value present value, so the load
generator covers write throughput and latency as well. Document the
available modes in the usage text and the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
Advance the vendored bacnet-stack to b4abcf1f1, which includes the upstream
fix (#1548) for the Schedule_Object_Name stack over-read the native fuzzer
found and we reported: the function no longer validates the UTF-8 of the
caller's (uninitialized) output string before filling it. Our
bp_schedule_object_name wrapper keeps initializing the string defensively;
its comment now reflects that the upstream bug is fixed.

The same range makes the Channel object's Control_Groups and
List_Of_Object_Property_References resizable BACnetARRAYs (#1543), which
rejects a write to an element past the current array size with Invalid
Array Index. server.addChannel now grows each array by writing its size to
array index 0 before setting the elements, instead of writing elements into
a fixed-size array.

Validated at the new pin: generated code unchanged, native -Werror compile
clean, dart format/analyze clean, unit and integration suites pass
(including the Channel WriteGroup end-to-end and the bacserv interop
suite), and 314k libFuzzer runs under ASan/UBSan with no findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
Set the package version to 0.9.0, date the 0.9.0 CHANGELOG section and add
its compare link, and update the README install snippet. No code changes.

Release-gated at this state: generated code regenerates with no diff, dart
format and analyze are clean, the native engine compiles with -Werror, and
tool/check_package.dart confirms the published files build and load the
native library (bacnet_plugin 0.9.0 against bacnet-stack 1.7.0-rc6). CI is
green on the preceding commit, which has identical code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3Woz4Bu11Eqhjsh6q3q5b
@gencto
gencto merged commit a89b2b7 into main Oct 12, 2026
11 checks passed
@gencto
gencto deleted the claude/keen-tesla-h9zjqd branch October 12, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants