Bluetooth Sensors: Pt. 4

Catch Up

I’ve let this project languish a little bit. Today I am going to pick it back up a bit and try to get it back on the rails.

Today’s Goal

I don’t have a lot of time this evening, so today I will simply try to set up my Artik-side program so that I can read a GATT attribute from the HC-08 radios. I will also set up the logic for subscribing to Notify events for that attribute so that I can monitor changes in its value.

Connecting to the discovered sensor

When I left off last, I was able to detect the sensors I was looking for. Now I want to expand on that so that I can read attributes of that sensor. That is where I will start.

First, I am going to refactor my code a little bit, changing the name of the showDeviceInfo() to deviceDiscovered(), since I am going to use it for more than just dumping information.

The next step is then to connect to the device so that we can start interacting with the characteristics. This is fairly straightforward:

if !dev.IsConnected() {
	if err := dev.Connect(); err != nil {
		log.Fatalf("Failed to connect to device: %s", err)
		return err
	}
}

Now one thing I realized while testing this is that if I start from a state where the Bluetooth radio on the Artik530 is powered off, this code will return an error saying “Failed to connect to device: Input/output error. I haven't been able to come up with a better solution to this than simply time.Sleep(time.Second)` after the initialization code. I don’t like this solution, but it appears in the muka code, so it doesn’t seem to just be me. Since this isn’t intended to be production code, I’m not going to dive further into this until it pops its ugly head out again.

Once we’re connected to the device, we then need to parse out the characteristic UUID that we’re looking for. We know the abbreviated UUID of the characteristic we want (FFE1), so we can query the device for it (note that I’ve added a helper function for converting the UUID from abbreviated to long form):

fullUuid := shortUuidToFull(CharacteristicId)
serialAttr, err := dev.GetCharByUUID(fullUuid)
if err != nil {
	log.Fatalf("Failed to find UUID: %s", err)
	return err
}

// Register for events, getting a channel to listen to.
channel, err := serialAttr.Register()
if err != nil {
	log.Fatalf("Failed to register: %v", err)
	return err
}

This code will register for notifications for the attribute that we requested from the device by UUID. The channel variable defined in this snippet will receive events from the DBus library with changes. This is getting us pretty close to what we’re looking for! I set up a quick goroutine to monitor for changes:

go func() {
	for event := range channel {
		if event == nil {
			return
		}
		log.Printf("Attribute Event: %v", event)
}()

if err = serialAttr.StartNotify(); err != nil {
	log.Fatalf("Failed to start notification: %v", err)
	return err
}

I see some messages going by, but not the ones I’m looking for; this is because I’m not actually sending anything from the HC-08 side.

Loopback Test

I open an SSH connection to the Artik530 board so that I can run another session to simulate an Arduino sensor. For now I’m just going to echo some raw text to /dev/ttyAMA4 (I had to look it up in an old post – hey, this was useful to me!).

echo "this is a test" > /dev/ttyAMA4

Note that this works out of the box because both the HC-08 serial port and Artik530 interface default to 9600 8N1.

And lo and behold, I see an event pop out of my Go program! Woo!

Event Parsing

So my event code now can receive events, but I notice that they’re not super easily decoded. First of all, I see several events, not simply ones for the characteristic I’m interested in. I need to do some filtering in the channel listener.

Characteristic Filtering

The goroutine that’s receiving the events over the channel needs to figure out how to simply ignore all events that aren’t what we’re looking for. To start, I look at the muka example tags and it appears that they precompute the value of the UUID and service mapping that we should look for – the Bluetooth device has a certain path to the service it exposes that maps to a given UUID. The event includes that service path that we must then identify as being from a device that we care about.

The code is pretty ugly, but it seems to work and it’s used in all of the muka sensors, so I’m going to use that method for now:

serv, err := dev.GetAllServicesAndUUID()
if err != nil {
	log.Fatalf("Failed to get all services and uuid: %v", err)
	return err
}

var uuidAndService string
for _, s := range serv {
	val := strings.Split(s, ":")
	if val[0] == fullUuid {
		uuidAndService = val[1]
		break
	}
}

Then, inside the goroutine that receives events over the event channel, I add some logic to check for whether this event came from the attribute path associated with our UUID:

if strings.Contains(fmt.Sprint(event.Path), uuidAndService) {
	// Stuff
}

With this, I am able then to simply react to just the events that I care about.

Decoding the Event

The Body of the event contains a few different things. I note that the muka code checks the event Body index zero to ensure that it is GattCharacteristic1Interface – I am not sure why this is, and will revisit this later. The real data from the event is in Body[1], which is a map of string to DBus Variant type. This contains the updated information of the characteristic’s property.

To be extra safe, I do the same checks that the muka code does: make sure that we can parse the properties, and then essentially cast it to a byte slice. From this byte slice we can then create a string, since we know a-priori that this property is indeed a string.

props := event.Body[1].(map[string]dbus.Variant)
if _, ok := props["Value"]; !ok {
	continue
}

b := props["Value"].Value().([]byte)
log.Printf("New Value: %v", string(b))

and with this, I am able to parse the updated property! I test that I am able to print this information as expected, and now it’s time to clean things up a bit.

Refactor

So right now my code is fairly linear: I listen for new devices, if they’re of the class that I expect, I connect to them and listen for changes to the characteristic that I care about (which is basically just a string). What I’m going to want is a way to receive these events from multiple sensors and to log those to the cloud(tm) somewhere. I’m thinking that since I could have multiple devices sending data at potentially overlapping times, this sounds like a job for Go’s channels.

Channels

It seems like I’ll want one place to receive the events from the sensors, and this will be responsible for logging the readings to the cloud. I’ll create a function that will be run as a goroutine waiting for a message to be passed into a channel and then logging it. So the obvious question is: what gets passed through the channel?

Message

From the sensor side, I’ll have two pieces of information: temperature and humidity. The sensor side won’t have a good clock, so I’m not going to try to do anything fancy there. I’ll have the Artik timestamp the reading when it receives the BLE notification of the property change. Our data isn’t super time critical, so I’m not too concerned about the accuracy for this. From the sensor side, I’d prefer to keep the data format as simple as possible. I’m going to set the characteristic to one single string that contains the information that I want. For now, I’ll use a comma-separated scheme: ,

In the meantime, the data passed via the message channel will be just a string received from the sensor, as well as fields for the MAC address of the sensor and the Timestamp of the message. For the sensor ID I’m just going to use the MAC Address of the sensor bluetooth radio for now, and the timestamp will simply be set when the message is read.

On the Go uploading side, I’ll parse this into a structure. I’ll have the two sensor fields, the timestamp and the sensor ID (MAC address).

type Measurement struct {
	Address         string  `json:"Address"`
	DegreesC        float32 `json:"DegreesC"`
	HumidityPercent float32 `json:"HumidityPercent"`
}

type LoggingMessage struct {
	Address   string
	Timestamp time.Time
	Message   string
}

MessageHandler

For starters, the function for listening for logging messages is very simple:

func handleMessages(messageChannel <-chan LoggingMessage) {
	for msg := range messageChannel {
		log.Printf("Message: %v, From: %v", msg.Message, msg.Address)
	}
}

This will simply wait for events on the logging channel and print them. This will have to be extended to log the data to a server someplace.

Discovery changes

On the discovery side, I then need to take a channel as an argument so that we can alert the receiving end of changes to the property we care about. This is fairly straightforward, albeit still a bit ugly. Extending the code from above, I now simply create a message object to send into the channel:

var msg LoggingMessage
msg.Timestamp = time.Now()
msg.Address = dev.Properties.Address
msg.Message = string(b)

loggingChannel <- msg

Summary

I’ve extended the code from last time to:

  1. Connect to a device
  2. Identify and subscribe to a GATT characteristic that we care about
  3. Receive property change notifications and parse them for values
  4. Refactor the code somewhat to provide a single path for receiving property changes from multiple devices

Final Code

package main

import (
	"errors"
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/godbus/dbus"
	"github.com/muka/go-bluetooth/api"
	"github.com/muka/go-bluetooth/bluez"
	"github.com/muka/go-bluetooth/bluez/profile"
	"github.com/muka/go-bluetooth/emitter"
)

const (
	CustomUuid       = "ffe0"
	CharacteristicId = "ffe1"
)

type Measurement struct {
	Address         string  `json:"Address"`
	DegreesC        float32 `json:"DegreesC"`
	HumidityPercent float32 `json:"HumidityPercent"`
}

type LoggingMessage struct {
	Address   string
	Timestamp time.Time
	Message   string
}

func main() {
	loggingChannel := make(chan LoggingMessage)
	go handleMessages(loggingChannel)

	err := api.TurnOnBluetooth()
	if err != nil {
		log.Fatalf("Failed to enable bluetooth (%s)", err)
	}

	manager, err := api.NewManager()
	if err != nil {
		log.Fatalf("Failed to get new Manager: %s", err)
	}

	defer api.Exit()

	err = api.On("adapter", emitter.NewCallback(func(ev emitter.Event) {
		adapterEvent := ev.GetData().(api.AdapterEvent)
		if adapterEvent.Status == api.DeviceAdded {
			foundAdapter(adapterEvent.Name, loggingChannel)
		}
	}))

	err = manager.RefreshState()
	if err != nil {
		log.Fatalf("Failed to refresh Manager state: %s", err)
	}

	time.Sleep(time.Second)

	// Now get a list of the previously identified BlueZ devices and
	// print their information to the log.
	devices, err := api.GetDevices()
	if err != nil {
		log.Fatalf("Failed to get cached devices: %s", err)
	}

	log.Printf("======================================")
	log.Printf("Cached Devices:")
	log.Printf("======================================")
	for _, dev := range devices {
		deviceDiscovered(&dev, loggingChannel)
	}
	log.Printf("======================================")

	select {}
}

func handleMessages(messageChannel <-chan LoggingMessage) {
	for msg := range messageChannel {
		log.Printf("Message: %v, From: %v", msg.Message, msg.Address)
	}
}

func foundAdapter(adapterId string, loggingChannel chan<- LoggingMessage) {
	adapter := profile.NewAdapter1(adapterId)

	// Make sure that the adapter is powered on. Note that because
	// we made a call to api.TurnOnBluetooth() in main, we expect that
	// rfkill has enabled the device.
	if !adapter.Properties.Powered {
		err := adapter.SetProperty("Powered", dbus.MakeVariant(true))
		if err != nil {
			log.Fatalf("Failed to set Powered property: %s", err)
		}
	}

	// Now that the adapter is on, we can start scanning for Bluetooth
	// devices.
	err := adapter.StartDiscovery()
	if err != nil {
		log.Fatalf("Failed to start discovery: %s", err)
	}

	err = api.On("discovery", emitter.NewCallback(func (ev emitter.Event) {
		discoveryEvent := ev.GetData().(api.DiscoveredDeviceEvent)
		if discoveryEvent.Status == api.DeviceAdded {
			log.Printf("Adapter: %v", adapter)
			deviceDiscovered(discoveryEvent.Device, loggingChannel)
		}
	}))
}

func deviceIsSensor(dev *api.Device) bool {
	props, err := dev.GetProperties()
	if err != nil {
		log.Printf("Failed to read device properties: %s", err)
		return false
	}

	log.Printf("Name: %s, Addr: %s, RSSI: %d", props.Name, props.Address, props.RSSI)

	// We're going to look for the truncated UUID, which is a bit
	// hacky
	for _, uuid := range props.UUIDs {
		r := []rune(uuid)
		substring := string(r[4:8])
		if substring == CustomUuid {
			return true
		}
	}

	return false
}

func shortUuidToFull(uuid string) string {
	return "0000" + uuid + "-0000-1000-8000-00805F9B34FB"
}

func deviceDiscovered(dev *api.Device, loggingChannel chan<- LoggingMessage) (error) {
	if dev == nil {
		return errors.New("received nil argument")
	} else if !deviceIsSensor(dev) {
		return errors.New("device is not a sensor")
	}

	if !dev.IsConnected() {
		if err := dev.Connect(); err != nil {
			log.Fatalf("Failed to connect to device: %s", err)
			return err
		}
	}

	// Let's find the characteristic we want
	fullUuid := shortUuidToFull(CharacteristicId)
	serialAttr, err := dev.GetCharByUUID(fullUuid)
	if err != nil {
		log.Fatalf("Failed to find UUID: %s", err)
		return err
	}
	log.Printf("Characteristic: %v", serialAttr)

	serv, err := dev.GetAllServicesAndUUID()
	if err != nil {
		log.Fatalf("Failed to get all services and uuid: %v", err)
		return err
	}

	var uuidAndService string
	for _, s := range serv {
		val := strings.Split(s, ":")
		if val[0] == fullUuid {
			uuidAndService = val[1]
			break
		}
	}

	// Register for events, getting a channel to listen to.
	channel, err := serialAttr.Register()
	if err != nil {
		log.Fatalf("Failed to register: %v", err)
		return err
	}

	go func() {
		for event := range channel {
			if event == nil {
				return
			}
			log.Printf("event: %v (Sender: %v)", event, event.Sender)
			if strings.Contains(fmt.Sprint(event.Path), uuidAndService) {
				switch event.Body[0].(type) {
				case dbus.ObjectPath:
					continue
				case string:
					break
				}

				if event.Body[0] != bluez.GattCharacteristic1Interface {
					continue
				}

				props := event.Body[1].(map[string]dbus.Variant)
				if _, ok := props["Value"]; !ok {
					continue
				}

				b := props["Value"].Value().([]byte)

				var msg LoggingMessage
				msg.Timestamp = time.Now()
				msg.Address = dev.Properties.Address
				msg.Message = string(b)

				loggingChannel <- msg
			}
		}
	}()

	if err = serialAttr.StartNotify(); err != nil {
		log.Fatalf("Failed to start notification: %v", err)
		return err
	}

	return nil
}