Skip to content
Version 6.1.0
Contact Us

BACnet/SC 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:

  1. The connectionString passed in is the WebSocket URI as a byte array (not the 6-byte IP+port tuple).
  2. The networkPortInstance value 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 with BACNET_NETWORK_PORT_DEFAULT rather than guess its transport.
  3. There is no concept of broadcast at the WebSocket level. On BACnet/SC the broadcast flag in the send callback is always false: 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 messageLength when the whole frame is accepted (written to the socket or queued), and 0 when it is not: an unknown or closed connection, a transport error, or a full transmit queue. A 0 fails 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_Retries times (3 by default), waiting APDU_Timeout each 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 0 when 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 every BACnetStack_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. Use WebsocketStatus_Error (value 4) with the RFC 6455 close code in errorCode (or 0 if there is none; an operating-system or TLS error number is not a valid value there) for a failure — the stack maps errorCode to a BACnet error only for this status — and WebsocketStatus_Disconnected (value 3) for a clean close. The status values are tabled in §19.

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 the sourceConnectionString/connectionString you report to ReceiveMessageForPort.
  • Store the mapping from your own socket/connection handle to that same string, and reuse it for every subsequent ReceiveMessageForPort call for frames from that socket, and match against it to select the right socket when the stack calls your SendMessageForPort callback 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.

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.