Add support for scanning for devices

There are some limitations, but it basically works (on both Linux and
nrf).
This commit is contained in:
Ayke van Laethem 2020-05-27 23:13:04 +02:00
parent 93550127da
commit 7a11ef8562
No known key found for this signature in database
GPG key ID: E97FF5335DFDFDED
7 changed files with 443 additions and 11 deletions

View file

@ -8,9 +8,10 @@ import (
)
type Adapter struct {
adapter *adapter.Adapter1
id string
handler func(Event)
adapter *adapter.Adapter1
id string
handler func(Event)
cancelScan func()
}
// DefaultAdapter returns the default adapter on the current system. On Linux,

View file

@ -58,6 +58,7 @@ func init() {
// SoftDevice on the chip.
type Adapter struct {
isDefault bool
scanning bool
handler func(Event)
charWriteHandlers []charWriteHandler
}
@ -117,21 +118,34 @@ func handleEvent() {
id := eventBuf.header.evt_id
switch {
case id >= C.BLE_GAP_EVT_BASE && id <= C.BLE_GAP_EVT_LAST:
connHandle := eventBuf.evt.unionfield_gap_evt().conn_handle
gapEvent := GAPEvent{
Connection: Connection(connHandle),
}
gapEvent := eventBuf.evt.unionfield_gap_evt()
switch id {
case C.BLE_GAP_EVT_CONNECTED:
handler := defaultAdapter.handler
if handler != nil {
handler(&ConnectEvent{GAPEvent: gapEvent})
handler(&ConnectEvent{GAPEvent: GAPEvent{Connection(gapEvent.conn_handle)}})
}
case C.BLE_GAP_EVT_DISCONNECTED:
handler := defaultAdapter.handler
if handler != nil {
handler(&DisconnectEvent{GAPEvent: gapEvent})
handler(&DisconnectEvent{GAPEvent: GAPEvent{Connection(gapEvent.conn_handle)}})
}
case C.BLE_GAP_EVT_ADV_REPORT:
advReport := gapEvent.params.unionfield_adv_report()
if debug && &scanReportBuffer.data[0] != advReport.data.p_data {
// Sanity check.
panic("scanReportBuffer != advReport.p_data")
}
// Prepare the globalScanResult, which will be passed to the
// callback.
scanReportBuffer.len = byte(advReport.data.len)
globalScanResult.RSSI = int16(advReport.rssi)
globalScanResult.Address = advReport.peer_addr.addr
globalScanResult.AdvertisementPayload = &scanReportBuffer
// Signal to the main thread that there was a scan report.
// Scanning will be resumed (from the main thread) once the scan
// report has been processed.
gotScanReport.Set(1)
case C.BLE_GAP_EVT_CONN_PARAM_UPDATE_REQUEST:
// Respond with the default PPCP connection parameters by passing
// nil:
@ -140,11 +154,11 @@ func handleEvent() {
// > NULL is provided on a central role and in response to a
// > BLE_GAP_EVT_CONN_PARAM_UPDATE_REQUEST, the peripheral request
// > will be rejected
C.sd_ble_gap_conn_param_update(connHandle, nil)
C.sd_ble_gap_conn_param_update(gapEvent.conn_handle, nil)
case C.BLE_GAP_EVT_DATA_LENGTH_UPDATE_REQUEST:
// We need to respond with sd_ble_gap_data_length_update. Setting
// both parameters to nil will make sure we send the default values.
C.sd_ble_gap_data_length_update(connHandle, nil, nil)
C.sd_ble_gap_data_length_update(gapEvent.conn_handle, nil, nil)
default:
if debug {
println("unknown GAP event:", id)

25
examples/scanner/main.go Normal file
View file

@ -0,0 +1,25 @@
package main
import (
"github.com/aykevl/go-bluetooth"
)
func main() {
// Enable BLE interface.
adapter, err := bluetooth.DefaultAdapter()
must("get default adapter", err)
must("enable adapter", adapter.Enable())
// Start scanning.
println("scanning...")
err = adapter.Scan(func(adapter *bluetooth.Adapter, device bluetooth.ScanResult) {
println("found device:", device.Address.String(), device.RSSI, device.LocalName())
})
must("start scan", err)
}
func must(action string, err error) {
if err != nil {
panic("failed to " + action + ": " + err.Error())
}
}

114
gap.go
View file

@ -1,5 +1,13 @@
package bluetooth
import "errors"
var (
errScanning = errors.New("bluetooth: a scan is already in progress")
errNotScanning = errors.New("bluetooth: there is no scan in progress")
errMalformedAdvertisementPayload = errors.New("bluetooth: malformed advertisement packet")
)
// AdvertiseOptions configures everything related to BLE advertisements.
type AdvertiseOptions struct {
Interval AdvertiseInterval
@ -32,3 +40,109 @@ type ConnectEvent struct {
type DisconnectEvent struct {
GAPEvent
}
// ScanResult contains information from when an advertisement packet was
// received. It is passed as a parameter to the callback of the Scan method.
type ScanResult struct {
// MAC address of the scanned device.
Address MAC
// RSSI the last time a packet from this device has been received.
RSSI int16
// The data obtained from the advertisement data, which may contain many
// different properties.
// Warning: this data may only stay valid until the next event arrives. If
// you need any of the fields to stay alive until after the callback
// returns, copy them.
AdvertisementPayload
}
// AdvertisementPayload contains information obtained during a scan (see
// ScanResult). It is provided as an interface as there are two possible
// implementations: an implementation that works with raw data (usually on
// low-level BLE stacks) and an implementation that works with structured data.
type AdvertisementPayload interface {
// LocalName is the (complete or shortened) local name of the device.
// Please note that many devices do not broadcast a local name, but may
// broadcast other data (e.g. manufacturer data or service UUIDs) with which
// they may be identified.
LocalName() string
// Bytes returns the raw advertisement packet, if available. It returns nil
// if this data is not available.
Bytes() []byte
}
// AdvertisementFields contains advertisement fields in structured form.
type AdvertisementFields struct {
// The LocalName part of the advertisement (either the complete local name
// or the shortened local name).
LocalName string
}
// advertisementFields wraps AdvertisementFields to implement the
// AdvertisementPayload interface. The methods to implement the interface (such
// as LocalName) cannot be implemented on AdvertisementFields because they would
// conflict with field names.
type advertisementFields struct {
AdvertisementFields
}
// LocalName returns the underlying LocalName field.
func (p *advertisementFields) LocalName() string {
return p.AdvertisementFields.LocalName
}
// Bytes returns nil, as structured advertisement data does not have the
// original raw advertisement data available.
func (p *advertisementFields) Bytes() []byte {
return nil
}
// rawAdvertisementPayload encapsulates a raw advertisement packet. Methods to
// get the data (such as LocalName()) will parse just the needed field. Scanning
// the data should be fast as most advertisement packets only have a very small
// (3 or so) amount of fields.
type rawAdvertisementPayload struct {
data [31]byte
len uint8
}
// Bytes returns the raw advertisement packet as a byte slice.
func (buf *rawAdvertisementPayload) Bytes() []byte {
return buf.data[:buf.len]
}
// findField returns the data of a specific field in the advertisement packet.
func (buf *rawAdvertisementPayload) findField(fieldType byte) []byte {
data := buf.Bytes()
for len(data) >= 2 {
fieldLength := data[0]
if int(fieldLength)+1 > len(data) {
// Invalid field length.
return nil
}
if fieldType == data[1] {
return data[2 : fieldLength+1]
}
data = data[fieldLength+1:]
}
return nil
}
// LocalName returns the local name (complete or shortened) in the advertisement
// payload.
func (buf *rawAdvertisementPayload) LocalName() string {
b := buf.findField(9) // Complete Local Name
if len(b) != 0 {
println("complete")
return string(b)
}
b = buf.findField(8) // Shortened Local Name
if len(b) != 0 {
println("shortened")
return string(b)
}
return ""
}

View file

@ -6,7 +6,9 @@ import (
"errors"
"github.com/muka/go-bluetooth/api"
"github.com/muka/go-bluetooth/bluez/profile/adapter"
"github.com/muka/go-bluetooth/bluez/profile/advertising"
"github.com/muka/go-bluetooth/bluez/profile/device"
)
var (
@ -78,3 +80,133 @@ func (a *Advertisement) Start() error {
}
return nil
}
// Scan starts a BLE scan. It is stopped by a call to StopScan. A common pattern
// is to cancel the scan when a particular device has been found.
//
// On Linux with BlueZ, incoming packets cannot be observed directly. Instead,
// existing devices are watched for property changes. This closely simulates the
// behavior as if the actual packets were observed, but it has flaws: it is
// possible some events are missed and perhaps even possible that some events
// are duplicated.
func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) error {
if a.cancelScan != nil {
return errScanning
}
// This appears to be necessary to receive any BLE discovery results at all.
defer a.adapter.SetDiscoveryFilter(nil)
err := a.adapter.SetDiscoveryFilter(map[string]interface{}{
"Transport": "le",
})
if err != nil {
return err
}
// Instruct BlueZ to start discovering.
err = a.adapter.StartDiscovery()
if err != nil {
return err
}
// Listen for newly found devices.
discoveryChan, cancelChan, err := a.adapter.OnDeviceDiscovered()
if err != nil {
return err
}
a.cancelScan = cancelChan
// Obtain a list of cached devices to watch.
// BlueZ won't show advertisement data as it is discovered. Instead, it
// caches all the data and only produces events for changes. Worse: it
// doesn't seem to remove cached devices for a long time (3 minutes?) so
// simply reading the list of cached devices won't tell you what devices are
// actually around right now.
// Luckily, there is a workaround. When any value changes, you can be sure a
// new advertisement packet has been received. The RSSI value changes almost
// every time it seems so just watching property changes is enough to get a
// near-accurate view of the current state of the world around the listening
// device.
devices, err := a.adapter.GetDevices()
if err != nil {
return err
}
for _, dev := range devices {
a.startWatchingDevice(dev, callback)
}
// Iterate through new devices as they become visible.
for result := range discoveryChan {
if result.Type != adapter.DeviceAdded {
continue
}
// We only got a DBus object path, so turn that into a Device1 object.
dev, err := device.NewDevice1(result.Path)
if err != nil || dev == nil {
continue
}
// Signal to the API client that a new device has been found.
callback(a, makeScanResult(dev))
// Start watching this new device for when there are property changes.
a.startWatchingDevice(dev, callback)
}
return nil
}
// StopScan stops any in-progress scan. It can be called from within a Scan
// callback to stop the current scan. If no scan is in progress, an error will
// be returned.
func (a *Adapter) StopScan() error {
if a.cancelScan == nil {
return errNotScanning
}
a.adapter.StopDiscovery()
cancel := a.cancelScan
a.cancelScan = nil
cancel()
return nil
}
// makeScanResult creates a ScanResult from a Device1 object.
func makeScanResult(dev *device.Device1) ScanResult {
// Assume the Address property is well-formed.
addr, _ := ParseMAC(dev.Properties.Address)
return ScanResult{
RSSI: dev.Properties.RSSI,
Address: addr,
AdvertisementPayload: &advertisementFields{
AdvertisementFields{
LocalName: dev.Properties.Name,
},
},
}
}
// startWatchingDevice starts watching for property changes in the device.
// Errors are ignored (for example, if watching the device failed).
// The dev object will be owned by the function and will be modified as
// properties change.
func (a *Adapter) startWatchingDevice(dev *device.Device1, callback func(*Adapter, ScanResult)) {
ch, err := dev.WatchProperties()
if err != nil {
// Assume the device has disappeared or something.
return
}
go func() {
for change := range ch {
// Update the device with the changed property.
props, _ := dev.Properties.ToMap()
props[change.Name] = change.Value
dev.Properties, _ = dev.Properties.FromMap(props)
// Signal to the API client that a property changed, as if this was
// an incoming BLE advertisement packet.
callback(a, makeScanResult(dev))
}
}()
}

View file

@ -2,6 +2,11 @@
package bluetooth
import (
"device/arm"
"runtime/volatile"
)
/*
// Define SoftDevice functions as regular function declarations (not inline
// static functions).
@ -11,6 +16,13 @@ package bluetooth
*/
import "C"
// Memory buffers needed by sd_ble_gap_scan_start.
var (
scanReportBuffer rawAdvertisementPayload
gotScanReport volatile.Register8
globalScanResult ScanResult
)
// Advertisement encapsulates a single advertisement instance.
type Advertisement struct {
handle uint8
@ -54,3 +66,65 @@ func (a *Advertisement) Start() error {
errCode := C.sd_ble_gap_adv_start(a.handle, C.BLE_CONN_CFG_TAG_DEFAULT)
return makeError(errCode)
}
// Scan starts a BLE scan. It is stopped by a call to StopScan. A common pattern
// is to cancel the scan when a particular device has been found.
func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) error {
if a.scanning {
// There is a possible race condition here if Scan() is called from a
// different goroutine, but that is not allowed (and will likely result
// in an error below anyway).
return errScanning
}
a.scanning = true
scanParams := C.ble_gap_scan_params_t{}
scanParams.set_bitfield_extended(0)
scanParams.set_bitfield_active(0)
scanParams.interval = 100 * 1000 / 625 // 100ms in 625µs units
scanParams.window = 100 * 1000 / 625 // 100ms in 625µs units
scanParams.timeout = C.BLE_GAP_SCAN_TIMEOUT_UNLIMITED
scanReportBufferInfo := C.ble_data_t{
p_data: &scanReportBuffer.data[0],
len: uint16(len(scanReportBuffer.data)),
}
errCode := C.sd_ble_gap_scan_start(&scanParams, &scanReportBufferInfo)
if errCode != 0 {
return Error(errCode)
}
// Wait for received scan reports.
for a.scanning {
// Wait for the next advertisement packet to arrive.
// TODO: use some sort of condition variable once the scheduler supports
// them.
arm.Asm("wfe")
if gotScanReport.Get() == 0 {
// Spurious event. Continue waiting.
continue
}
gotScanReport.Set(0)
// Call the callback with the scan result.
callback(a, globalScanResult)
// Restart the advertisement. This is needed, because advertisements are
// automatically stopped when the first packet arrives.
errCode := C.sd_ble_gap_scan_start(nil, &scanReportBufferInfo)
if errCode != 0 {
return Error(errCode)
}
}
return nil
}
// StopScan stops any in-progress scan. It can be called from within a Scan
// callback to stop the current scan. If no scan is in progress, an error will
// be returned.
func (a *Adapter) StopScan() error {
if !a.scanning {
return errNotScanning
}
a.scanning = false
return nil
}

72
mac.go Normal file
View file

@ -0,0 +1,72 @@
package bluetooth
import "errors"
// MAC represents a MAC address, in little endian format.
type MAC [6]byte
var errInvalidMAC = errors.New("bluetooth: failed to parse MAC address")
// ParseMAC parses the given MAC address, which must be in 11:22:33:AA:BB:CC
// format. If it cannot be parsed, an error is returned.
func ParseMAC(s string) (mac MAC, err error) {
macIndex := 11
for i := 0; i < len(s); i++ {
c := s[i]
if c == ':' {
continue
}
var nibble byte
if c >= '0' && c <= '9' {
nibble = c - '0' + 0x0
} else if c >= 'A' && c <= 'F' {
nibble = c - 'A' + 0xA
} else {
err = errInvalidMAC
return
}
if macIndex < 0 {
err = errInvalidMAC
return
}
if macIndex%2 == 0 {
mac[macIndex/2] |= nibble
} else {
mac[macIndex/2] |= nibble << 4
}
macIndex--
}
return
}
// String returns a human-readable version of this MAC address, such as
// 11:22:33:AA:BB:CC.
func (mac MAC) String() string {
// TODO: make this more efficient.
s := ""
for i := 5; i >= 0; i-- {
c := mac[i]
// Insert a hyphen at the correct locations.
if i != 5 {
s += ":"
}
// First nibble.
nibble := c >> 4
if nibble <= 9 {
s += string(nibble + '0')
} else {
s += string(nibble + 'A' - 10)
}
// Second nibble.
nibble = c & 0x0f
if nibble <= 9 {
s += string(nibble + '0')
} else {
s += string(nibble + 'A' - 10)
}
}
return s
}