Let’s get right into the weeds on how to use Go to connect to Bluetooth Low Energy (BLE) devices.
If you are unclear of how BLE works, please read this here first: https://learn.adafruit.com/introduction-to-bluetooth-low-energy . We will be using BLE terminology which is required to progress.
In the following, we will use the TinyGo Bluetooth Low Energy library to connect to a thermometer. The thermometer will be the peripheral and our computer the central.
As the thermometer, we will use the AOJ-20A , which is a generic BLE thermometer used by several brands. If you have a different one at hand, the exact BLE services, BLE characteristics, and byte decoding might not be the same.
Setup #
Create a new project:
mkdir go-ble-thermometer &&
cd go-ble-thermometer &&
go mod init go-ble-thermometer &&
touch main.go
Fill main.go with the following content:
package main
import "fmt"
func main() {
// connect to BLE device
}
Add TinyGo BLE library #
First, make sure to have the requirements fulfilled, follow this guide according to your operating system.
After that, add the library to our project:
go get tinygo.org/x/bluetooth
Now with the setup out of the way, let’s get ahead with scanning.
Scanning #
First of all, what does scanning mean? BLE devices (peripherals) advertise their presence. Some devices do that all the time, others only when an action, such as a measurement has taken place. The thermometer for this example, advertises only after a measurement has taken place.
Advertisements contain information such as the device name and supported services. A list of services definitions can be found here .
For example, the thermometer for this test uses a custom service for the measurements, 0xFE00. Thermometers that follow the specification would advertise the temperature service 0x1082.
Let’s set up the scanning part in our main.go:
package main
import (
"tinygo.org/x/bluetooth"
)
func main() {
adapter := bluetooth.DefaultAdapter
// Enable adapter
err := adapter.Enable()
if err != nil {
panic("failed to enable BLE adapter")
}
// Start scanning and define callback for scan results
err = adapter.Scan(onScan)
if err != nil {
panic("failed to register scan callback")
}
}
func onScan(adapter *bluetooth.Adapter, device bluetooth.ScanResult) {
log.Println("found device:", device.Address.String(), device.RSSI, device.LocalName(), device.AdvertisementPayload)
}
Running that code, should give us an output like this.
The output varies, depending on the operating system. MacOS hides and replaces the device mac address with a UUID, such as 420ffebb-8db9-33d5-76ca-c2e0a2d634eb
for example.
2022/12/04 18:45:26 found device: 420ffebb-8db9-33d5-76ca-c2e0a2d634eb -78 &{{ [] map[76:[18 2 0 0]]}}
2022/12/04 18:45:26 found device: bba75c5e-d1de-5621-67aa-db402f52d1fb -83 &{{ [] map[76:[16 6 46 30 134 178 12 214]]}}
2022/12/04 18:45:26 found device: bba75c5e-d1de-5621-67aa-db402f52d1fb -84 &{{ [] map[76:[16 6 46 30 134 178 12 214]]}}
2022/12/04 18:45:26 found device: 209da95c-dee8-7665-1d28-32cc4f1402d8 -67 &{{ [0000febe-0000-1000-8000-00805f9b34fb] map[784:[64 17 1 48]]}}
2022/12/04 18:45:26 found device: 209da95c-dee8-7665-1d28-32cc4f1402d8 -67 LE-Bose Revolve+ SoundLink &{{LE-Bose Revolve+ SoundLink [0000febe-0000-1000-8000-00805f9b34fb] map[784:[64 17 1 48]]}}
Connecting #
Depending on your use case, you would like to connect to the device by specific mac address. As I am currently using MacOS, I will filter based on the supported services.
At first, let us define the service from the thermometer:
// UUID for temperature service
const BleServiceTemperature = 0xFFE0
func onScan(adapter *bluetooth.Adapter, device bluetooth.ScanResult) {
log.Println("found device:", device.Address.String(), device.RSSI, device.LocalName(), device.AdvertisementPayload)
if device.HasServiceUUID(bluetooth.New16BitUUID(BleServiceTemperature)) {
log.Println("found device with temperature service:", device.Address.String(), device.RSSI, device.LocalName())
}
}
Which will give us the following output when run:
2022/12/04 18:50:26 found device: 3893cb43-9197-895d-adbc-81a084360444 -51 &{{ [0000ffe0-0000-1000-8000-00805f9b34fb] map[]}}
2022/12/04 18:50:26 found device with temperature service: 3893cb43-9197-895d-adbc-81a084360444 -51
2022/12/04 18:50:26 found device: 3893cb43-9197-895d-adbc-81a084360444 -51 AOJ-20A &{{AOJ-20A [0000ffe0-0000-1000-8000-00805f9b34fb] map[426:[193 236 14 82 1 113 229 77 243 56 193 164]]}}
2022/12/04 18:50:26 found device with temperature service: 3893cb43-9197-895d-adbc-81a084360444 -51 AOJ-20A
Now, let us remove all the logs of unnecessary devices and connect to it:
func onScan(adapter *bluetooth.Adapter, device bluetooth.ScanResult) {
//log.Println("found device:", device.Address.String(), device.RSSI, device.LocalName(), device.AdvertisementPayload)
if device.HasServiceUUID(bluetooth.New16BitUUID(BleServiceTemperature)) {
log.Println("found device with temperature service:", device.Address.String(), device.RSSI, device.LocalName())
// Start connecting in a goroutine to not block
go func() {
res, err := adapter.Connect(device.Address, bluetooth.ConnectionParams{})
if err != nil {
println("error connecting:", err.Error())
return
}
// Call connect callback
onConnect(device, res)
}()
}
}
func onConnect(scanResult bluetooth.ScanResult, device *bluetooth.Device) {
println("connected:", scanResult.Address.String(), scanResult.LocalName())
}
This gives us the following output:
2022/12/04 18:56:53 found device with temperature service: 3893cb43-9197-895d-adbc-81a084360444 -45 AOJ-20A
2022/12/04 18:56:53 found device with temperature service: 3893cb43-9197-895d-adbc-81a084360444 -46 AOJ-20A
2022/12/04 18:56:53 found device with temperature service: 3893cb43-9197-895d-adbc-81a084360444 -47 AOJ-20A
2022/12/04 18:56:53 connected: 3893cb43-9197-895d-adbc-81a084360444 AOJ-20A
Nice, let’s go first find the correct service with the UID of BleServiceTemperature
.
func onConnect(scanResult bluetooth.ScanResult, device *bluetooth.Device) {
log.Println("connected:", scanResult.Address.String(), scanResult.LocalName())
// Get a list of services
services, err := device.DiscoverServices([]bluetooth.UUID{
bluetooth.New16BitUUID(BleServiceTemperature),
})
// If error, bail out
if err != nil {
println("error getting services:", err.Error())
return
}
// Iterate services
for _, service := range services {
if service.UUID() != bluetooth.New16BitUUID(BleServiceTemperature) {
// Wrong service
continue
}
// Found the correct service
}
}
Awesome, let’s go ahead and find the correct characteristic. First, let us define the UUID of the characteristic:
// UUID for temperature characteristic
const BleCharacteristicTemperature = 0xFFE1
Then change the onConnect
:
func onConnect(scanResult bluetooth.ScanResult, device *bluetooth.Device) {
log.Println("connected:", scanResult.Address.String(), scanResult.LocalName())
// Get a list of services
services, err := device.DiscoverServices([]bluetooth.UUID{
bluetooth.New16BitUUID(BleServiceTemperature),
})
// If error, bail out
if err != nil {
log.Println("error getting services:", err.Error())
return
}
// Iterate services
for _, service := range services {
if service.UUID() != bluetooth.New16BitUUID(BleServiceTemperature) {
// Wrong service
continue
}
// Found the correct service
// Get a list of characteristics below the service
characteristics, err := service.DiscoverCharacteristics([]bluetooth.UUID{
bluetooth.New16BitUUID(BleCharacteristicTemperature),
})
// If error, bail out
if err != nil {
println("error getting characteristics:", err.Error())
return
}
// Iterate characteristics
for _, characteristic := range characteristics {
err := characteristic.EnableNotifications(<<DEFINE_ME>>)
// If error, bail out
if err != nil {
println("error enabling notifications:", err.Error())
return
}
}
}
}
Inside the characteristics iterator, we will add now a receiver for notifications for the temperature characteristic.
Let’s first define the receiver:
func characteristicReceiverTemperature(buf []byte) {
log.Printf("received: %x", buf)
}
And update the onConnect receiver definition:
// Iterate characteristics
for _, characteristic := range characteristics {
// Subscribe to notification from the characteristic with
// the `characteristicReceiverTemperature` callback receiver
err := characteristic.EnableNotifications(characteristicReceiverTemperature)
// If error, bail out
if err != nil {
println("error enabling notifications:", err.Error())
return
}
}
With this defined, a test should yield the following output:
2022/12/04 18:57:53 connected: 3893cb43-9197-895d-adbc-81a084360444 AOJ-20A
2022/12/04 18:57:53 received: aa01c1f80e48017f
2022/12/04 18:57:53 received: aa01c1f80e48017f
Parsing Byte Buffer #
The byte buffer now contains our measurement, but how do we know what is what? If this was a standard service/characteristic, the byte array definition would be available in the BLE standard. Unfortunately, the device I have uses a custom service.
So, with careful testing, I discovered that the 5th and 6th position in the byte array represent the temperature. Also, it turns out, the first three bytes define if a measurement was successful or failed.
// Success
aa 01 c1
// Error
aa 01 ce
//1 2 3 4 5 6 7 8
aa 01 c1 f8 0e 48 01 7f
In the example measurement, the values are 0e and 48. Written together and converted to decimal, the value of 0x0e48 is 3656 in decimal. This matches as the measurement was 36.6 °C on the device.
To convert this now from the byte array over to the decimal value, lets fill out the code in characteristicReceiverTemperature
.
func characteristicReceiverTemperature(buf []byte) {
temp := (float64(buf[4]) * 256) + float64(buf[5])
temp = math.Round(temp/10) / 10
log.Printf("temp: %v", temp)
}
Upon testing, the output should look like this:
2022/12/04 19:23:15 temp: 36.5
2022/12/04 19:23:17 temp: 35.8
Conclusion #
Thanks having reached the end of this article, I hope you have learned how to connect to BLE devices via the TinyGo BLE library!
The code is available on GitHub .