BACnet/SC transport callbacks
18. Implementing the Transport Callbacks
Section titled “18. Implementing the Transport Callbacks”This is the core of any BACnet/SC integration. The transport callbacks are send and receive (18.1), initiate and disconnect (18.2) and start and stop listening (18.3). The state-change callback (Section 20) is optional. One notification call flows back into the stack (Section 19). Take the time to get this right - the rest of the integration is plumbing.
18.1 Send and receive (BVLC-SC message bytes)
Section titled “18.1 Send and receive (BVLC-SC message bytes)”The same RegisterCallbackSendMessageForPort / RegisterCallbackReceiveMessageForPort callbacks used by BACnet/IP are also used for BACnet/SC. On BACnet/SC each message is one whole BVLC-SC message, which is one WebSocket binary frame: the stack builds its control messages (Connect-Request, Heartbeat-Request, Disconnect-Request, Advertisements and the rest) and sends them through the same send callback as the Encapsulated-NPDUs, and the receive callback hands back every frame as it arrived. Treat the bytes as opaque; do not parse or build NPDUs. The two transports differ in:
- The
connectionStringpassed in is the WebSocket URI as a byte array (not the 6-byte IP+port tuple). - The
networkPortInstancevalue is the BACnet/SC Network Port object’s instance (kSCNetworkPortInstance). The send callback is told which port to send on; the receive callback must report the port a frame arrived on. A build with more than one transport compiled in must report a real instance - the stack drops a received frame reported withBACNET_NETWORK_PORT_DEFAULTrather than guess its transport. - There is no concept of broadcast at the WebSocket level. On BACnet/SC the
broadcastflag in the send callback is alwaysfalse: the host always sends on the connection the connection string names, and a broadcast is addressed by the destination VMAC inside the frame (the hub forwards it to all peers).
Return value contract for the send callback (CallbackSendMessageForPort): the return value is not a boolean success flag. On success your callback must return exactly messageLength — the number of bytes it was given to send — not merely a nonzero or true-like value. Return 0 if the connection is unknown, closed, or the send otherwise failed. The stack compares the returned count against the length it asked for and treats anything other than an exact match as a failed send, so a callback that returns 1 (or any other nonzero value that isn’t the exact byte count) on success will cause every send to be silently treated as having failed, even though bytes did go out on the wire. The example below follows this contract (... ? messageLength : 0) — do not simplify it to a plain boolean-returning pattern.
// Single global receive slot for clarity. In production, use a thread-safe// queue with one entry per delivered WebSocket frame.// Size it to the Max_BVLC_Length_Accepted the port advertises (6144 by default in a// STACK_OPTION_TARGET_FULL build): peers and the hub send frames up to that size.static uint8_t g_recvBuf[6144];static uint16_t g_recvLen = 0;static char g_recvFromUri[256];static uint8_t g_recvFromUriLen = 0;
uint16_t CallbackReceiveMessage( uint8_t* message, const uint16_t maxMessageLength, uint8_t* sourceConnectionString, uint8_t* sourceConnectionStringLength, uint8_t* destConnectionString, uint8_t* destConnectionStringLength, const uint8_t maxConnectionStringLength, uint32_t* networkPortInstance){ if (g_recvLen == 0) { return 0; // nothing to deliver this Tick }
if (g_recvLen > maxMessageLength) { g_recvLen = 0; // drop oversize frames return 0; }
memcpy(message, g_recvBuf, g_recvLen); uint16_t len = g_recvLen; g_recvLen = 0;
uint8_t uriLen = g_recvFromUriLen; if (uriLen > maxConnectionStringLength) { uriLen = maxConnectionStringLength; } memcpy(sourceConnectionString, g_recvFromUri, uriLen); *sourceConnectionStringLength = uriLen;
*destConnectionStringLength = 0; // unused for SC receive *networkPortInstance = kSCNetworkPortInstance; // the port this frame arrived on return len;}
uint16_t CallbackSendMessage( const uint8_t* message, const uint16_t messageLength, const uint8_t* connectionString, const uint8_t connectionStringLength, const uint32_t networkPortInstance, bool broadcast){ if (networkPortInstance != kSCNetworkPortInstance) { // Not the SC port - let your other transport handle it // (e.g., BACnet/IP) or return 0. return 0; }
char uri[260] = {0}; // connectionStringLength is a uint8_t, so it always fits memcpy(uri, connectionString, connectionStringLength); uri[connectionStringLength] = '\0';
// On BACnet/SC the stack always passes broadcast == false. The connection string already names the // WebSocket connection to use, and a broadcast is addressed by the destination VMAC inside the frame, // so send on exactly that connection. (void)broadcast; return MyWs_SendBinary(uri, message, messageLength) ? messageLength : 0;}How the receive buffer gets filled depends on your WebSocket library. The contract is:
// Called by your WebSocket library when a binary frame arrives from `uri`.void OnWebSocketFrame(const char* uri, const uint8_t* data, uint16_t len){ if (len > sizeof(g_recvBuf)) return; memcpy(g_recvBuf, data, len); g_recvLen = len; strncpy(g_recvFromUri, uri, sizeof(g_recvFromUri) - 1); g_recvFromUriLen = (uint8_t)strlen(g_recvFromUri);}18.1.1 Delivery semantics and transmit-queue guidance
Section titled “18.1.1 Delivery semantics and transmit-queue guidance”Returning messageLength from the send callback means accepted for transmission, not delivered. The stack has no later failure channel for a frame it has already handed you, and there is no “delivery failed” callback. What that means in practice:
- Your return value is the only failure report. Return
messageLengthwhen the whole frame is accepted (written to the socket or queued), and0when it is not: an unknown or closed connection, a transport error, or a full transmit queue. A0fails that send immediately (and is logged through the API debug log when that logging is compiled in and enabled). - A failure after you returned success is invisible to the stack. A lost confirmed request (ReadProperty, WriteProperty, …) is handled by the stack’s own transaction timeout and retries: it re-sends the request up to
Number_Of_APDU_Retriestimes (3 by default), waitingAPDU_Timeouteach time, and the application gets the usual timeout once the retries are used up. A host that wants no retries serves the property as 0 through the unsigned integer property callback. Lost unconfirmed traffic (I-Am, COV notifications sent unconfirmed, …) is simply gone, exactly as with BACnet/IP over UDP. - Bound your per-connection transmit queue. If you queue frames for a socket, cap the queue and return
0when it is full rather than growing it without limit. A peer that has stopped reading must not be able to exhaust your memory. - Never block inside the callback. It runs inside
BACnetStack_Tick()and synchronously inside everyBACnetStack_Send*call. Waiting on the network there stalls the whole stack. - When a socket fails after the fact, drop its queued frames, close it, and tell the stack with
BACnetStack_SetBACnetSCWebSocketStatus, so the stack’s connection state machines react (reconnect, failover) instead of continuing to address a dead peer. UseWebsocketStatus_Error(value 4) with the RFC 6455 close code inerrorCode(or 0 if there is none; an operating-system or TLS error number is not a valid value there) for a failure — the stack mapserrorCodeto a BACnet error only for this status — andWebsocketStatus_Disconnected(value 3) for a clean close. The status values are tabled in §19.
18.2 Initiate / disconnect WebSocket
Section titled “18.2 Initiate / disconnect WebSocket”CallbackInitiateWebsocket is called when the stack needs to open an outbound WebSocket connection — typically a Node connecting to its Hub, or a Node initiating a Direct Connection to a peer. It is also used by a Hub if it is configured to connect to another Hub as a client.
CallbackDisconnectWebsocket is called to close any connection, outbound or accepted, and every role needs it. For an outbound connection it passes the URI the stack gave CallbackInitiateWebsocket; for an accepted one (the hub function closing a peer it refused, timed out or replaced, or a direct connection this node accepted) it passes the per-peer <acceptUri>|client=<token> string you reported for that socket (§18.3.1). Look the string up in both sets of sockets.
// Stack asks: "Open a WebSocket to <uri>". You start the connect; you do NOT// wait for it to complete here. When TLS+WS upgrade succeeds, your code calls// BACnetStack_SetBACnetSCWebSocketStatus(uri, ..., kStatusConnected, 0).bool CallbackInitiateWebsocket(const char* uri, uint32_t uriLen){ char tmp[260] = {0}; memcpy(tmp, uri, uriLen); return MyWs_BeginConnect(tmp, OnWebSocketFrame, OnWebSocketConnected, OnWebSocketDisconnected, OnWebSocketError);}
// Stack asks: "Close that WebSocket cleanly".void CallbackDisconnectWebsocket(const char* uri, uint32_t uriLen){ char tmp[260] = {0}; memcpy(tmp, uri, uriLen); MyWs_Disconnect(tmp);}18.3 Start / stop listening (Hub, or Node accepting Direct Connections)
Section titled “18.3 Start / stop listening (Hub, or Node accepting Direct Connections)”CallbackSCStartListening is called when the stack needs your application to open an inbound listener on a URI so that other devices can connect to this device. This is required for:
- BACnet/SC Hub — must accept connections from all Nodes on the network.
- BACnet/SC Node with Direct Connect Accept enabled — must accept peer-to-peer connections.
A Node that only connects to a Hub and never accepts Direct Connections can register stubs:
bool CallbackSCStartListening(const char* uri, uint32_t uriLen) { return false; }void CallbackSCStopListening (const char* uri, uint32_t uriLen) { (void)uri; }If you enable the hub function or Direct Connect Accept, replace these stubs with code that runs a wss:// server on the given URI.
18.3.1 Identifying accepted peers: the <acceptUri>|client=<N> connection-string convention
Section titled “18.3.1 Identifying accepted peers: the <acceptUri>|client=<N> connection-string convention”CallbackSCStartListening tells you which URI to accept connections on, but the stack has no separate “a client connected” callback — it only learns about an accepted peer from that peer’s first frame (Connect-Request). This means your application, not the stack, is responsible for minting a per-connection identity for each accepted socket, and that identity is the connectionString you must pass consistently to every later ReceiveMessageForPort/SendMessageForPort call for that peer.
The expected shape is:
<acceptUri>|client=<token>where <acceptUri> is exactly the bare URI string the stack gave you in CallbackSCStartListening (the listening/accept URI, used for egress addressing when the stack means “the listener itself”, not a specific peer), and <token> is a suffix your application chooses per accepted connection. It must be non-empty, unique among the sockets currently open, and stable for that socket’s lifetime; it does not need to be numeric. For example, if your application accepted a connection on wss://0.0.0.0:47808/hub, a per-peer connection string might be wss://0.0.0.0:47808/hub|client=3 or wss://0.0.0.0:47808/hub|client=peer-a1b2c3.
The stack recognizes this shape: a connectionString that exactly matches a configured accept URI is treated as addressing the listener itself, and one that is <acceptUri>|client= followed by at least one character is treated as addressing that specific accepted peer. <acceptUri>|client= with an empty token does not match.
Which string goes where on receive. Report the per-socket <acceptUri>|client=<token> string as sourceConnectionString. For destinationConnectionString, pass either the bare <acceptUri> or nothing (length 0): the stack matches the accept URI against the destination first and falls back to the source only when the destination is empty. A destination that does not match a configured accept URI — your host’s own address, say — makes every frame from that peer unattributable, and it is dropped as coming from an unknown URI (logged only when API debug logging is compiled in and enabled).
Practically, this means:
- The FIRST time you see a frame from a newly-accepted socket, generate a connection string of this shape (e.g. append your own incrementing counter or connection handle after
|client=) and use it as thesourceConnectionString/connectionStringyou report toReceiveMessageForPort. - Store the mapping from your own socket/connection handle to that same string, and reuse it for every subsequent
ReceiveMessageForPortcall for frames from that socket, and match against it to select the right socket when the stack calls yourSendMessageForPortcallback with that connection string. - This convention is internal plumbing between your application and this stack, not part of the BACnet/SC wire protocol itself — it is never sent on the wire and other devices never see it.
19. Notifying the Stack of WebSocket State Changes
Section titled “19. Notifying the Stack of WebSocket State Changes”The stack needs to know when each WebSocket transitions between states. There is exactly one notification call:
DllExport bool BACnetStack_SetBACnetSCWebSocketStatus( const char* uri, const uint16_t uriLength, const uint8_t status, // see WebsocketStatus_* below const uint32_t errorCode); // RFC 6455 close code for WebsocketStatus_Error; otherwise 0// Returns true if a websocket or accepted peer matching uri was found and updated.Status values for the status argument:
| Constant | Value | Use |
|---|---|---|
WebsocketStatus_Unknown |
0 | Reserved, do not use |
WebsocketStatus_Connecting |
1 | Optional: an outbound open has started but has not completed yet |
WebsocketStatus_Connected |
2 | Call when a WebSocket has fully completed TLS + HTTP Upgrade |
WebsocketStatus_Disconnected |
3 | Call when a WebSocket closes cleanly |
WebsocketStatus_Error |
4 | Call when a WebSocket fails or closes abnormally; put the RFC 6455 close code (or 0 if there is none) in errorCode |
Only WebsocketStatus_Error maps errorCode to a BACnet error and records a failed connection (it
reaches the Network Port’s connection-status and failed-connection-request properties);
WebsocketStatus_Disconnected is a plain, silent return to not-connected.
Wire your WebSocket library callbacks like this:
void OnWebSocketConnected(const char* uri){ BACnetStack_SetBACnetSCWebSocketStatus(uri, (uint16_t)strlen(uri), 2 /* Connected */, 0);}
void OnWebSocketDisconnected(const char* uri) // clean close{ BACnetStack_SetBACnetSCWebSocketStatus(uri, (uint16_t)strlen(uri), 3 /* WebsocketStatus_Disconnected */, 0);}
void OnWebSocketError(const char* uri, uint32_t closeCode /* RFC 6455, or 0 if none */){ BACnetStack_SetBACnetSCWebSocketStatus(uri, (uint16_t)strlen(uri), 4 /* WebsocketStatus_Error */, closeCode);}RFC 6455 close codes (1000–1011) are recognised by the stack and mapped to BACnet websocket-error enumerations, then surfaced through the network-port connection-status property.
20. State-Change Callback (Debug and Observability)
Section titled “20. State-Change Callback (Debug and Observability)”The state-change callback (BACnetStack_RegisterCallbackBACnetSCStateChange) is used to observe internal BACnet/SC state-machine transitions. It is intended for debug logging — use it to trace connection lifecycle events, diagnose unexpected disconnects, and drive UI status indicators. It is not required for correct protocol operation.
The stack raises this callback for transitions in four state machines, numbered in stateMachine: 0 hub-connector, 1 hub-connection status, 2 initiating peer, and 3 WebSocket status (the table below gives their state values).
void CallbackSCStateChange( uint32_t deviceInstance, uint32_t networkPortInstance, uint8_t stateMachine, // 0 hub-connector, 1 hub-connection, 2 peer, 3 websocket uint8_t previousState, uint8_t newState, const char* websocketUri, uint32_t websocketUriLen){ // websocketUri CAN BE NULL (length 0) for a transition not tied to one socket. char uri[260] = {0}; if (websocketUri != NULL) { size_t copyLength = websocketUriLen < sizeof(uri) - 1 ? websocketUriLen : sizeof(uri) - 1; memcpy(uri, websocketUri, copyLength); } Log("SC state: dev=%u port=%u machine=%u %u -> %u uri=%s", deviceInstance, networkPortInstance, stateMachine, previousState, newState, uri);}Use this to drive UI state (“Connecting… / Connected / Reconnecting in 12 s…”), log files, and SNMP/MIB traps. The callback is informational only - no action is required.
stateMachine names the machine that moved, and previousState / newState are values of that machine’s own
enumeration:
stateMachine |
Machine | State values |
|---|---|---|
| 0 | Hub connector (internal) | 0 start, 1 try primary, 2 connected to primary, 3 wait primary, 4 try failover, 5 connected to failover, 6 wait failover |
| 1 | Hub-connection status | 0 no hub connection, 1 connected to primary, 2 connected to failover |
| 2 | Initiating peer (direct connect) | 0 idle, 1 awaiting WebSocket, 2 awaiting accept, 3 connected, 4 disconnecting |
| 3 | WebSocket status | 0 unknown, 1 connecting, 2 connected, 3 disconnected, 4 error |
The hub-connection status (stateMachine 1) is the value the Network Port exposes as SC_Hub_Connector_State
(4194318): no-hub-connection (0), connected-to-primary (1) and connected-to-failover (2). The hub-connector
machine (stateMachine 0) is the stack’s internal state machine; its numbers are not the property’s values.
21. Sending I-Am Over BACnet/SC
Section titled “21. Sending I-Am Over BACnet/SC”I-Am is a BACnet broadcast announcement. Over BACnet/SC, “broadcast” means the stack addresses the frame to the broadcast VMAC and hands it to your send callback for the hub connection (with the callback’s broadcast flag still false); the hub forwards it to all connected nodes.
const char* hubUri = "wss://hub.example.com:4443/bacnet-sc";BACnetStack_SendIAm( kDeviceInstance, (const uint8_t*)hubUri, (uint8_t)strlen(hubUri), kSCNetworkPortInstance, // networkPortInstance /*broadcast =*/ true, /*destinationNetwork =*/ 65535, /*destinationAddress =*/ NULL, /*destinationAddressLength =*/ 0);You typically issue an I-Am when the hub-connection status (stateMachine 1) becomes 1 (connected-to-primary)
or 2 (connected-to-failover), or in response to a Who-Is. The stack’s Who-Is handling is automatic - the manual call shown above is for unsolicited announcements only.