A Go implementation of the BACnet/IP protocol stack for building automation and control systems.
- BACnet/IP Protocol: Full support for BACnet/IP communication
- Device Discovery: Who-Is and I-Am services for network device discovery
- Object Access: ReadProperty, ReadMultipleProperty, WriteProperty, WriteMultipleProperty
- Network Management: What-Is-Network-Number, Who-Is-Router-To-Network
- Transaction Management: TSM (Transaction State Machine) for confirmed services
- Concurrency: Thread-safe design with connection pooling
- BACnet Server: Full server-side implementation with object store, automatic WhoIs→IAm responses, and confirmed service handling
- Room Simulator:
cmd/room-simulator— YABE-friendly virtual room device (see ROOM_SIMULATOR.md)
go get github.com/anviod/bacnetEdge / cross-compile (static, no cgo): ./scripts/cross-build.sh or make cross, e.g. CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build ./... (ARMv7+).
package main
import (
"fmt""log""github.com/anviod/bacnet""github.com/anviod/bacnet/btypes"
)
funcmain() {
// Create a BACnet clientclient, err:=bacnet.NewClient(&bacnet.ClientBuilder{
Ip: "192.168.1.100",
SubnetCIDR: 24,
Port: 47808, // Default BACnet port (0xBAC0)
})
iferr!=nil {
log.Fatal(err)
}
deferclient.Close()
// Start the client message loopgoclient.ClientRun()
// Discover all devices on the networkdevices, err:=client.WhoIs(&bacnet.WhoIsOpts{
Low: 0,
High: 4194304, // Max BACnet device ID
})
iferr!=nil {
log.Fatal(err)
}
fmt.Printf("Discovered %d devices\n", len(devices))
for_, dev:=rangedevices {
fmt.Printf("Device ID: %d, IP: %s:%d\n", dev.DeviceID, dev.Ip, dev.Port)
}
}The BACnet data collection process consists of six key steps:
Before any communication can occur, a BACnet client must be created with appropriate network configuration.
client, err:=bacnet.NewClient(&bacnet.ClientBuilder{
Ip: "192.168.1.100", // Local IP addressSubnetCIDR: 24, // Subnet mask (e.g., /24)Port: 47808, // BACnet port (default: 47808)
})
iferr!=nil {
log.Fatal(err)
}
deferclient.Close()Configuration Options:
Ip: Local IP address to bind toInterface: Network interface name (alternative to Ip)SubnetCIDR: Subnet CIDR notation (e.g., 24 for 255.255.255.0)Port: BACnet UDP port (default: 47808 = 0xBAC0)MaxPDU: Maximum PDU size (default: 1476)
The client message loop must be started in a goroutine to handle incoming messages:
goclient.ClientRun()Important Notes:
- Must be called before making any requests
- Runs continuously until the client is closed
- Handles message decoding and routing
Discover BACnet devices on the network using the WhoIs service:
devices, err:=client.WhoIs(&bacnet.WhoIsOpts{
Low: 0, // Device ID lower boundHigh: 4194304, // Device ID upper bound (max)
})Discovery Options:
Low: Lower bound of device ID range (0 to 4194304)High: Upper bound of device ID rangeGlobalBroadcast: Use global broadcast address (0xFFFF)Destination: Specific target address for unicast discovery
Best Practices:
- Use narrow ID ranges for targeted discovery to reduce network traffic
- Avoid using full range (0-4194304) on large networks
- Cache discovered devices to avoid repeated discovery
Retrieve all objects from a discovered device:
scannedDevice, err:=client.Objects(devices[0])
iferr!=nil {
log.Printf("Failed to scan objects: %v", err)
return
}
// Access specific object typesaiObjects:=scannedDevice.Objects[btypes.AnalogInput]
biObjects:=scannedDevice.Objects[btypes.BinaryInput]
aoObjects:=scannedDevice.Objects[btypes.AnalogOutput]
boObjects:=scannedDevice.Objects[btypes.BinaryOutput]Supported Object Types:
AnalogInput(0): Analog input points (e.g., temperature sensors)AnalogOutput(1): Analog output points (e.g., valves, dampers)AnalogValue(2): Analog value objectsBinaryInput(3): Binary input points (e.g., contact sensors)BinaryOutput(4): Binary output points (e.g., relays)BinaryValue(5): Binary value objectsDevice(8): BACnet device objectsMultiStateInput(13): Multi-state input pointsMultiStateOutput(14): Multi-state output pointsTrendLog(20): Trend log objects
Read property values from device objects.
result, err:=client.ReadProperty(device, btypes.PropertyData{
Object: btypes.Object{
ID: btypes.ObjectID{
Type: btypes.AnalogInput,
Instance: 1,
},
Properties: []btypes.Property{
{
Type: btypes.PropPresentValue,
ArrayIndex: btypes.ArrayAll,
},
},
},
})For better performance, use ReadMultiProperty to read multiple properties in one request:
result, err:=client.ReadMultiProperty(device, btypes.MultiplePropertyData{
Objects: []btypes.Object{
{
ID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1},
Properties: []btypes.Property{
{Type: btypes.PropPresentValue},
{Type: btypes.PropUnits},
{Type: btypes.PropDescription},
},
},
{
ID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 2},
Properties: []btypes.Property{
{Type: btypes.PropPresentValue},
},
},
},
})Common Properties:
PropPresentValue(85): Current value of the objectPropUnits(117): Engineering unitsPropDescription(28): Object descriptionPropObjectName(77): Object namePropObjectType(79): Object typePropObjectIdentifier(75): Object identifierPropObjectList(76): List of objects in device
Write values to device objects.
err:=client.WriteProperty(device, btypes.PropertyData{
Object: btypes.Object{
ID: btypes.ObjectID{
Type: btypes.AnalogOutput,
Instance: 1,
},
Properties: []btypes.Property{
{
Type: btypes.PropPresentValue,
ArrayIndex: btypes.ArrayAll,
Data: float64(25.5),
Priority: btypes.Normal,
},
},
},
})Write Priority Levels:
LifeSafety(3): Life safety operationsCriticalEquipment(2): Critical equipment controlUrgent(1): Urgent operationsNormal(0): Normal operations
funccompleteIntegration(client bacnet.Client) error {
// Step 1: Discover devicesdevices, err:=client.WhoIs(&bacnet.WhoIsOpts{
Low: 0,
High: 4194304,
})
iferr!=nil {
returnfmt.Errorf("whois failed: %v", err)
}
iflen(devices) ==0 {
returnfmt.Errorf("no devices found")
}
device:=devices[0]
fmt.Printf("Found device: ID=%d, IP=%s:%d\n", device.DeviceID, device.Ip, device.Port)
// Step 2: Scan objectsscannedDevice, err:=client.Objects(device)
iferr!=nil {
returnfmt.Errorf("object scan failed: %v", err)
}
// Step 3: Find target pointaiObjects:=scannedDevice.Objects[btypes.AnalogInput]
targetPoint, ok:=aiObjects[1]
if!ok {
returnfmt.Errorf("target point not found")
}
fmt.Printf("Found target point: %s\n", targetPoint.Name)
// Step 4: Read present valueresult, err:=client.ReadProperty(device, btypes.PropertyData{
Object: btypes.Object{
ID: btypes.ObjectID{
Type: btypes.AnalogInput,
Instance: 1,
},
Properties: []btypes.Property{
{Type: btypes.PropPresentValue},
},
},
})
iferr!=nil {
returnfmt.Errorf("read property failed: %v", err)
}
fmt.Printf("Present Value: %v\n", result.Object.Properties[0].Data)
// Step 5: Write to AnalogValuewriteErr:=client.WriteProperty(device, btypes.PropertyData{
Object: btypes.Object{
ID: btypes.ObjectID{
Type: btypes.AnalogValue,
Instance: 1,
},
Properties: []btypes.Property{
{
Type: btypes.PropPresentValue,
ArrayIndex: btypes.ArrayAll,
Data: float64(25.5),
Priority: btypes.Normal,
},
},
},
})
ifwriteErr!=nil {
returnfmt.Errorf("write property failed: %v", writeErr)
}
fmt.Println("Write successful")
returnnil
}Use timeout variants for better control over request timing:
result, err:=client.ReadPropertyWithTimeout(device, propertyData, 5*time.Second)funcsafeReadProperty(client bacnet.Client, device btypes.Device, objID btypes.ObjectID) (interface{}, error) {
result, err:=client.ReadProperty(device, btypes.PropertyData{
Object: btypes.Object{
ID: objID,
Properties: []btypes.Property{
{Type: btypes.PropPresentValue},
},
},
})
iferr!=nil {
// Handle specific error typesifstrings.Contains(err.Error(), "timeout") {
returnnil, fmt.Errorf("device %d did not respond", device.DeviceID)
}
ifstrings.Contains(err.Error(), "no such object") {
returnnil, fmt.Errorf("object %s not found", objID.Type)
}
returnnil, err
}
iflen(result.Object.Properties) ==0 {
returnnil, fmt.Errorf("no properties returned")
}
returnresult.Object.Properties[0].Data, nil
}typeClientinterface {
io.CloserIsRunning() boolClientRun()
// Device DiscoveryWhoIs(wh*WhoIsOpts) ([]btypes.Device, error)
IAm(dest btypes.Address, iam btypes.IAm) error// Network ManagementWhatIsNetworkNumber() []*btypes.AddressWhoIsRouterToNetwork() (resp*[]btypes.Address)
// Object AccessObjects(dev btypes.Device) (btypes.Device, error)
ReadProperty(dest btypes.Device, rp btypes.PropertyData) (btypes.PropertyData, error)
ReadMultiProperty(dev btypes.Device, rp btypes.MultiplePropertyData) (btypes.MultiplePropertyData, error)
WriteProperty(dest btypes.Device, wp btypes.PropertyData) errorWriteMultiProperty(dev btypes.Device, wp btypes.MultiplePropertyData) error// Timeout variantsReadPropertyWithTimeout(dest btypes.Device, rp btypes.PropertyData, timeout time.Duration) (btypes.PropertyData, error)
ReadMultiPropertyWithTimeout(dev btypes.Device, rp btypes.MultiplePropertyData, timeout time.Duration) (btypes.MultiplePropertyData, error)
// COV SubscriptionSubscribeCOV(device btypes.Device, data btypes.SubscribeCOVData) errorCancelSubscribeCOV(device btypes.Device, processIDuint32, objectID btypes.ObjectID) errorWaitCOVNotification(processIDFilterint64, timeout time.Duration) (btypes.COVNotification, error)
}typeWhoIsOptsstruct {
Lowint// Device ID lower bound (0 to 4194304)Highint// Device ID upper boundGlobalBroadcastbool// Use global broadcast (0xFFFF)NetworkNumberuint16// Target network numberDestination*btypes.Address// Specific destination (optional)
}typeClientBuilderstruct {
DataLink datalink.DataLink// Custom data link (optional)Interfacestring// Network interface name (e.g., "eth0")Ipstring// IP addressPortint// BACnet port (default: 47808)SubnetCIDRint// Subnet CIDR (e.g., 24 for /24)MaxPDUuint16// Maximum PDU size (default: 1476)
}// ProtocolconstDefaultPort=0xBAC0// 47808constMaxAPDU=1476// NetworkconstGlobalBroadcast=0xFFFFconstDefaultHopCount=255// Prioritiesconst (
LifeSafety=3CriticalEquipment=2Urgent=1Normal=0
)Port Binding:
- Default BACnet port is 47808 (0xBAC0)
- Use different ports for testing to avoid conflicts
- Bind to
0.0.0.0to listen on all interfaces
IP Address Binding:
- Avoid binding to the target device's IP address
- For multi-subnet environments, configure subnet CIDR properly
Broadcast Behavior:
- WhoIs uses broadcast by default
- Use
Destinationfor unicast requests - Broadcast may not work across VLANs or subnets
Batch Operations:
- Use
ReadMultiPropertyfor reading multiple properties - Reduce network round-trips
- Limit batch size based on device's MaxAPDU setting
- Use
Concurrency:
- Client is thread-safe for concurrent operations
- TSM limits concurrent confirmed transactions (default: 10)
- Consider rate limiting for high-frequency operations
Memory Management:
- Use buffer pool for efficient memory usage
- Release resources promptly with
client.Close()
Timeout Handling:
- Use
ReadPropertyWithTimeoutfor explicit timeout control - Confirmed services include retry logic with exponential backoff
- Implement application-level retry for critical operations
- Use
Common Errors:
timeout: Device did not respond within timeoutinvalid argument: Invalid object type or property IDno such object: Requested object does not existaccess denied: Insufficient permissions for write operations
Possible Causes:
- Incorrect IP address or subnet configuration
- Firewall blocking BACnet port (47808)
- Devices on different VLAN/subnet
- Client not running (
ClientRun()not called)
Solutions:
- Verify network configuration
- Check firewall rules
- Use Wireshark to monitor BACnet traffic
- Ensure
ClientRun()is called beforeWhoIs()
Possible Causes:
- Device not responding
- Incorrect device address
- Network connectivity issues
- Device busy or overloaded
Solutions:
- Verify device is reachable via ping
- Check device address (some devices use different ports for confirmed services)
- Increase timeout value
- Implement retry logic
Possible Causes:
- Insufficient permissions
- Write protection enabled on device
- Incorrect priority level
Solutions:
- Check device configuration for write permissions
- Verify priority level (use appropriate priority)
- Contact device manufacturer for access rights
Possible Causes:
- Frequent WhoIs requests with full ID range
- Large batch operations exceeding MTU
- Broadcast storms
Solutions:
- Use targeted WhoIs with narrow ID ranges
- Limit batch size to stay within MaxAPDU
- Implement device discovery caching
The server package provides a complete BACnet/IP server implementation. It can act as a virtual BACnet device, responding to client requests with real data.
server 包提供了完整的 BACnet/IP 服务端实现。它可以作为虚拟 BACnet 设备运行,响应客户端请求并返回真实数据。
package main
import (
"log""os""os/signal""github.com/anviod/bacnet/server""github.com/anviod/bacnet/btypes"
)
funcmain() {
// Create server configurationcfg:=&server.DeviceConfig{
DeviceID: 1000, // BACnet device instance IDDeviceName: "My BACnet Server", // BACnet device nameVendorID: 999, // Vendor identifierIp: "0.0.0.0", // Bind to all interfacesPort: 47808, // BACnet portSubnetCIDR: 24, // Subnet mask
}
// Create the serversrv, err:=server.NewServer(cfg)
iferr!=nil {
log.Fatal(err)
}
defersrv.Close()
// Add a temperature sensor objectsrv.AddObject(btypes.Object{
ID: btypes.ObjectID{
Type: btypes.AnalogInput,
Instance: 1,
},
Properties: []btypes.Property{
{
Type: btypes.PROP_PRESENT_VALUE,
ArrayIndex: btypes.ArrayAll,
Data: float64(23.5), // Current temperature: 23.5°C
},
{
Type: btypes.PROP_OBJECT_NAME,
ArrayIndex: btypes.ArrayAll,
Data: "Room Temperature",
},
{
Type: btypes.PROP_Propertybtypes,
ArrayIndex: btypes.ArrayAll,
Data: uint32(62), // Degrees Celsius
},
{
Type: btypes.PROP_DESCRIPTION,
ArrayIndex: btypes.ArrayAll,
Data: "Office room temperature sensor",
},
},
})
// Add a binary output object (relay control)srv.AddObject(btypes.Object{
ID: btypes.ObjectID{
Type: btypes.BinaryOutput,
Instance: 1,
},
Properties: []btypes.Property{
{
Type: btypes.PROP_PRESENT_VALUE,
ArrayIndex: btypes.ArrayAll,
Data: uint32(0), // Off
},
{
Type: btypes.PROP_OBJECT_NAME,
ArrayIndex: btypes.ArrayAll,
Data: "Fan Control",
},
},
})
// Handle graceful shutdownc:=make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
// Start the server in a goroutinegofunc() {
log.Println("BACnet server starting...")
iferr:=srv.Serve(); err!=nil {
log.Printf("Server error: %v", err)
}
}()
log.Printf("BACnet server started. Device ID: %d, Name: %s", srv.GetDeviceID(), cfg.DeviceName)
log.Println("Press Ctrl+C to stop")
// Update values dynamically (simulate sensor readings)// go func() {// for {// time.Sleep(5 * time.Second)// srv.SetProperty(// btypes.AnalogInput, 1,// btypes.PROP_PRESENT_VALUE,// float64(20.0 + rand.Float64()*10.0),// )// }// }()<-clog.Println("Shutting down server...")
}The server maintains a thread-safe object store for BACnet objects:
// Add an objectsrv.AddObject(btypes.Object{
ID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1},
})
// Get an objectobj, found:=srv.GetObject(btypes.AnalogInput, 1)
// Remove an objectsrv.RemoveObject(btypes.AnalogInput, 1)
// Set a property valuesrv.SetProperty(btypes.AnalogInput, 1, btypes.PROP_PRESENT_VALUE, float64(25.0))
// Get a property valuevalue, found:=srv.GetProperty(btypes.AnalogInput, 1, btypes.PROP_PRESENT_VALUE)When a BACnet client sends a WhoIs broadcast, the server automatically responds with an IAm message if its device ID falls within the requested range:
// Client sends: WhoIs(0, 4194304) → all devices// Server responds: IAm(DeviceID=1000, ...)// Client sends: WhoIs(2000, 3000) → devices in range// Server (DeviceID=1000) does NOT respond (out of range)| Service | Description | Status |
|---|---|---|
| ReadProperty | 读取单个属性值 | Supported |
| WriteProperty | 写入单个属性值 | Supported |
| ReadPropertyMultiple | 批量读取多个属性 | Supported |
| WritePropertyMultiple | 批量写入多个属性 | Supported |
// Client subscribes to COV notificationserr:=client.SubscribeCOV(device, btypes.SubscribeCOVData{
ProcessID: 1,
ObjectID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1},
IssueConfirmed: false, // false = UnconfirmedCOVNotificationLifetime: 0, // 0 = permanent subscription
})
// Wait for COV notificationnotification, err:=client.WaitCOVNotification(1, 30*time.Second)
// notification.MonitoredObjectIdentifier → monitored object// notification.ListOfValues[0].Value → new value// Cancel subscriptionerr=client.CancelSubscribeCOV(device, 1, btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1})The server returns proper BACnet error responses for invalid requests:
| Error Condition | Error Class | Error Code |
|---|---|---|
| Unknown object | ObjectError | UnknownObject |
| Unknown property | PropertyError | UnknownProperty |
| Write access denied | PropertyError | WriteAccessDenied |
| Invalid request tag | ServicesError | InvalidTag |
| Missing parameter | ServicesError | MissingRequiredParameter |
| Unsupported service | ServicesError | ServiceRequestDenied |
The server automatically maintains the standard BACnet Device object properties:
| Property | Value |
|---|---|
| ObjectIdentifier | Configured device ID |
| ObjectName | Configured device name |
| VendorIdentifier | Configured vendor ID |
| VendorName | "BACnet-Go" |
| ModelName | "BACnet-Go Server" |
| FirmwareRevision | "1.0.0" |
| ProtocolVersion | 1 |
| ProtocolRevision | 24 |
| ProtocolServicesSupported | BitString (ReadProperty, WriteProperty, etc.) |
| ProtocolObjectTypesSupported | BitString (AI, AO, AV, BI, BO, BV, etc.) |
| MaxAPDUAccepted | 1476 |
| DatabaseRevision | Auto-incremented on changes |
When adding an object without specifying properties, the server creates default properties:
// Adding an object with no properties gets defaultssrv.AddObject(btypes.Object{
ID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1},
})
// Automatically creates: ObjectIdentifier, ObjectName, ObjectType,// PresentValue, StatusFlags, EventState, Reliability, OutOfService, Units// —— Server Side ——srv, _:=server.NewServer(&server.DeviceConfig{
DeviceID: 1000, DeviceName: "TestServer", VendorID: 999,
Ip: "0.0.0.0", Port: 47808, SubnetCIDR: 24,
})
gosrv.Serve()
defersrv.Close()
srv.AddObject(btypes.Object{
ID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1},
Properties: []btypes.Property{
{Type: btypes.PROP_PRESENT_VALUE, ArrayIndex: btypes.ArrayAll, Data: float64(42.5)},
},
})
// —— Client Side ——client, _:=bacnet.NewClient(&bacnet.ClientBuilder{
Ip: "192.168.1.100", SubnetCIDR: 24, Port: 47808,
})
goclient.ClientRun()
deferclient.Close()
// Discover the serverdevices, _:=client.WhoIs(&bacnet.WhoIsOpts{Low: 0, High: 4194304})
// Read property from the serverresult, _:=client.ReadProperty(devices[0], btypes.PropertyData{
Object: btypes.Object{
ID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1},
Properties: []btypes.Property{
{Type: btypes.PROP_PRESENT_VALUE, ArrayIndex: btypes.ArrayAll},
},
},
})
// result.Object.Properties[0].Data == float64(42.5)// Write property to the serverclient.WriteProperty(devices[0], btypes.PropertyData{
Object: btypes.Object{
ID: btypes.ObjectID{Type: btypes.AnalogInput, Instance: 1},
Properties: []btypes.Property{
{Type: btypes.PROP_PRESENT_VALUE, ArrayIndex: btypes.ArrayAll, Data: float64(99.9)},
},
},
})typeServerinterface {
// LifecycleServe() errorClose() errorIsRunning() bool// Object ManagementAddObject(obj btypes.Object) errorRemoveObject(objType btypes.ObjectType, instance btypes.ObjectInstance) errorGetObject(objType btypes.ObjectType, instance btypes.ObjectInstance) (*btypes.Object, bool)
SetProperty(objType btypes.ObjectType, instance btypes.ObjectInstance, propType btypes.PropertyType, datainterface{}) errorGetProperty(objType btypes.ObjectType, instance btypes.ObjectInstance, propType btypes.PropertyType) (interface{}, bool)
GetObjectStore() *ObjectStoreGetDeviceID() btypes.ObjectInstance
}typeDeviceConfigstruct {
DeviceID btypes.ObjectInstance// Device instance ID (0-4194304)DeviceNamestring// BACnet device nameVendorIDuint32// Vendor identifierInterfacestring// Network interface (e.g., "eth0")Ipstring// IP address to bindPortint// BACnet port (default: 47808)SubnetCIDRint// Subnet CIDR (e.g., 24)MaxPDUuint16// Maximum PDU sizeMaxSegmentsuint// Maximum segmentsSegmentation segmentation.SegmentedType// Segmentation support
}| Object Type | ID | Description |
|---|---|---|
| AnalogInput | 0 | 模拟输入 (e.g., temperature sensors) |
| AnalogOutput | 1 | 模拟输出 (e.g., valves, dampers) |
| AnalogValue | 2 | 模拟值 |
| BinaryInput | 3 | 二进制输入 (e.g., contact sensors) |
| BinaryOutput | 4 | 二进制输出 (e.g., relays) |
| BinaryValue | 5 | 二进制值 |
| Device | 8 | BACnet 设备对象 |
| MultiStateValue | 19 | 多状态值 |
# Run all tests
go test ./...
# Run server tests
go test ./server/...
# Run server tests with coverage
go test -cover ./server/...
# Run specific test
go test -v ./network/...
# Run acceptance tests
go test -v -run Acceptance
# Run real device integration test
go test. -run TestRealDeviceAcceptanceFlow -count=1 -vMIT License