Bluetooth Serial Port with Go and BlueZ
Last year, as part of my coursework (NTU’s CZ3004 Multi-disciplinary Project), I developed a simple router program in Go. This post will discuss programmatically interfacing with BlueZ, and implementing Bluetooth’s Serial Port Protocol (SPP) in Go.
(I actually meant to publish this some time last year. Unfortunately, coursework, exams, and other matters got in the way 🙃)
Background #
In a nutshell, the router program runs on a Raspberry Pi, and is responsible for interfacing with various components on different protocols, routing messages between them. As part of the coursework’s requirements, the router had to communicate with an Android application via Bluetooth. And the simplest way to pass (string-based) messages to-and-fro is via the SPP protocol, leading me to figure out how this can be done.
While this is extremely easy in Python, I decided to use Go for its more performant implementation of concurrency. Unfortunately, the Bluetooth ecosystem in Go is not as well-developed: while there are a couple of libraries that interface with BlueZ (e.g. muka/go-bluetooth or paypal/gatt), the official Linux Bluetooth protocol stack, they mainly targeted Bluetooth LE. However, RFCOMM (and thus SPP) is only supported in Bluetooth Classic. Thus, having never worked with the Bluetooth stack, I faced some difficulties in figuring out what needed to be done, and how to go about doing it.
Overall flow #
In modern Linux systems, Bluetooth is largely managed by BlueZ, the official Linux Bluetooth protocol stack. The general steps for establishing a SPP connection is as follows:
- Pair the devices
- Ensure our host Bluetooth device is open to page scans (this should be the default mode)
- Paging requests are what allows a connection to be established (see this blog post)
- Create a Serial Port (SP) channel, thereby advertising that we support serial port functionality
- Listen to the newly-created SP channel for incoming requests, and respond accordingly (accept) when they arrive
Step 1 is assumed to be done out-of-band, meaning the pairing was completed beforehand. Step 2, as mentioned, is typically the default state of an enabled Bluetooth radio on boot (as tested on the target Raspberry Pi 3B, running Raspbian). Steps 3 and 4 are handled programmatically.
Registering our SP channel #
To create a SP channel, I made use of the godbus/dbus package, which provides native Go client bindings for the D-Bus message bus system. This is the canonical way of communicating with BlueZ, starting from BlueZ 5. The code responsible is as follows (error handlers omitted):
// Interface with dbus, to announce our SPP service.
// Interface with system bus instead of session bus, since we expect
// BlueZ to be independent of user sessions
dbusConn, err := dbus.SystemBus()
// Bus name: identifies the target application via a "well-known" name
// Object path: names the object, from the application, that we want
bluezDbusObj := dbusConn.Object("org.bluez", "/org/bluez")
// func (o *Object) Call(method string, flags Flags, args ...interface{}) *Call
// RegisterProfile signature: osa{sv}
// (ObjectPath path, string UUID, map[string]interface{})
// Here, object path can be defined by us, and serves as a reference
// to our application as an "object" (e.g. namespacing)
myObjPath := dbus.ObjectPath("/foo/bar/baz")
bluezCall := bluezDbusObj.Call(
"org.bluez.ProfileManager1.RegisterProfile",
0,
myObjPath, // Object path
"00001101-0000-1000-8000-00805f9b34fb", // Well-known SPP UUID
map[string]dbus.Variant{ // Options
"ServiceRecord": dbus.MakeVariant(serviceRecord), // Discussed later
},
)
dbus.SystemBus()
and (*Conn).Object()
should be self-explanatory: the former instantiates a connection with the system bus; the latter is a reference to BlueZ’s D-Bus object. To understand (*Object).Call()
, we can refer to the package’s documentation, which states:
Call calls a method with (*Object).Go and waits for its reply.
As noted in the code comment above, dbusConn.Object().Call()
accepts three arguments: the method to call, call flags, and the arguments to be passed to the method. In D-Bus, methods are “operations that can be invoked on an object” (source). The correct method to invoke can be found by inspecting the D-Bus object, using DFeet for example.
The method’s arguments depend on the specific method being called. In our case, RegisterProfile
has osa{sv}
as its method signature, per inspection via DFeet. We can choose to decode this, by referring to the summary of types in the D-Bus specifications. Or, we can refer to the Profile API description found in the BlueZ source, which tells us that RegisterProfile
has the following interface:
void RegisterProfile(object profile, string uuid, dict options)
Thus, we observe that we need three things: the profile to be registered (our object path), the service UUID to be registered, and any option flags we wish to pass to the profile manager.
For the service UUID, the Bluetooth Technology Website has a reference list of assigned numbers that can be used. Specifically, we are interested in the base UUID (00000000-0000-1000-8000-00805F9B34FB
), and the UUID for the SerialPort Service Class (0x1011
). By replacing the first 32 bits of the base UUID with the SP Service Class UUID, we get the desired complete UUID: 00001101-0000-1000-8000-00805f9b34fb
.
As for the options, there is one we need to pass to the profile manager: the ServiceRecord. This can be obtained by manually defining a profile and extracting it out via sdptool
:
sdptool add --channel=<channel number> SP
sdptool browse --xml local
For ease of reference, here is the ServiceRecord I extracted from my Raspberry Pi 3B, and used in my program. Depending on the device used, this might vary.
<record>
<attribute id="0x0001">
<sequence>
<uuid value="0x1101"/>
</sequence>
</attribute>
<attribute id="0x0004">
<sequence>
<sequence>
<uuid value="0x0100"/>
</sequence>
<sequence>
<uuid value="0x0003"/>
<uint8 value="0x07" name="channel"/>
</sequence>
</sequence>
</attribute>
<attribute id="0x0100">
<text value="Serial Port" name="name"/>
</attribute>
</record>
With this, we have successfully registered our profile with BlueZ, assuming there was no error returned from bluezDbusObj.Call()
. Note that this only needs to be declared once per program run. Thus, even if there is a Bluetooth disconnect for some reason, there is no need to re-register the SP channel.
Listening to the SP channel #
Now that the hard part is over, we simply need to listen for incoming connections, and accept them. This can be achieved by binding to the appropriate Unix socket, using Go’s sys/unix. Once that is achieved, it is a simple matter of listening for incoming connections, and accepting them upon receipt. The code below is how I implemented it, largely referencing the example given in the package documentation (again, error handlers omitted):
// Get a reference to the RFCOMM Bluetooth socket
sock, err := unix.Socket(syscall.AF_BLUETOOTH, syscall.SOCK_STREAM, unix.BTPROTO_RFCOMM)
// Define the address we want to accept connections from. Here, we are
// accepting connections from anyone, thus we use the wildcard address.
addr := &unix.SockaddrRFCOMM{
Addr: str2ba("00:00:00:00:00:00"),
Channel: btChannel,
}
// Bind to the socket
err = unix.Bind(sock, addr)
// Listen for incoming connections
err = unix.Listen(sock, 1)
// Accept incoming connections, storing the client
// socket file descriptor and socket address
cSock, sa, err := unix.Accept(sock)
fmt.Printf("connection accepted from %s\n",
ba2str(sa.(*unix.SockaddrRFCOMM).Addr))
// We can safely close the reference to the RFCOMM socket here,
// since we already have a handle for the client socket.
unix.Close(sock)
And with that, we have obtained a file descriptor (our cSock
) that can be
read from and written to 🎉! These can be done by calling the respective functions exposed by the sys/unix package (remember to bring your own error handlers!):
// To read data
length, err := unix.Read(cSock, data)
if length > 0 {
dataStr := string(data[:length])
}
// To write data
msglen, err := unix.Write(cSock, []byte(msg))
if msglen != len(msg) {
// Not all data written! Handle appropriately
}
str2ba and ba2str #
These helper functions simply convert MAC addresses between string representation and little-endian byte arrays. str2ba
is from this Stack Overflow solution by Daniyal Guliev.
// str2ba converts MAC address string representation to little-endian byte array
func str2ba(addr string) [6]byte {
a := strings.Split(addr, ":")
var b [6]byte
for i, tmp := range a {
u, _ := strconv.ParseUint(tmp, 16, 8)
b[len(b)-1-i] = byte(u)
}
return b
}
// ba2str converts MAC address little-endian byte array to string representation
func ba2str(addr [6]byte) string {
return fmt.Sprintf("%2.2X:%2.2X:%2.2X:%2.2X:%2.2X:%2.2X",
addr[5], addr[4], addr[3], addr[2], addr[1], addr[0])
}
Conclusion #
With the above code, I was able to successfully communicate with an Android tablet via SPP. Hopefully, this has been helpful to anyone who wishes to utilise RFCOMM/SPP in Go.