diff --git a/adapter_linux.go b/adapter_linux.go index 10c697b..c3eb660 100644 --- a/adapter_linux.go +++ b/adapter_linux.go @@ -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, diff --git a/adapter_sd.go b/adapter_sd.go index d2ec455..6fe65f6 100644 --- a/adapter_sd.go +++ b/adapter_sd.go @@ -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) diff --git a/examples/scanner/main.go b/examples/scanner/main.go new file mode 100644 index 0000000..a661703 --- /dev/null +++ b/examples/scanner/main.go @@ -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()) + } +} diff --git a/gap.go b/gap.go index fa57d86..f361624 100644 --- a/gap.go +++ b/gap.go @@ -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 "" +} diff --git a/gap_linux.go b/gap_linux.go index bbabe19..6fd8bfe 100644 --- a/gap_linux.go +++ b/gap_linux.go @@ -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)) + } + }() +} diff --git a/gap_sd.go b/gap_sd.go index 3e8ede1..078eb51 100644 --- a/gap_sd.go +++ b/gap_sd.go @@ -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 +} diff --git a/mac.go b/mac.go new file mode 100644 index 0000000..0476380 --- /dev/null +++ b/mac.go @@ -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 +}