From 59a2afa5a1c3bd871e7187f31918a3e7fcd5a67c Mon Sep 17 00:00:00 2001 From: Thomas Kohler Date: Sat, 30 Dec 2023 15:14:55 +0100 Subject: [PATCH] ble(client): add scan timout --- platforms/bleclient/ble_client_adaptor.go | 191 +++++++++------ .../bleclient/ble_client_adaptor_options.go | 30 +++ .../ble_client_adaptor_options_test.go | 27 +++ .../bleclient/ble_client_adaptor_test.go | 221 +++++++++++++++++- platforms/bleclient/btwrapper.go | 122 ++++++++++ 5 files changed, 517 insertions(+), 74 deletions(-) create mode 100644 platforms/bleclient/ble_client_adaptor_options.go create mode 100644 platforms/bleclient/ble_client_adaptor_options_test.go create mode 100644 platforms/bleclient/btwrapper.go diff --git a/platforms/bleclient/ble_client_adaptor.go b/platforms/bleclient/ble_client_adaptor.go index f73605930..bd44fe5b2 100644 --- a/platforms/bleclient/ble_client_adaptor.go +++ b/platforms/bleclient/ble_client_adaptor.go @@ -11,112 +11,165 @@ import ( "gobot.io/x/gobot/v2" ) -var ( - currentAdapter *bluetooth.Adapter - bleMutex sync.Mutex -) +type bleAdaptorConfiguration struct { + scanTimeout time.Duration + debug bool +} -// Adaptor represents a client connection to a BLE Peripheral +// Adaptor represents a Client Connection to a BLE Peripheral type Adaptor struct { - name string - address string - AdapterName string + name string + identifier string + clientCfg *bleAdaptorConfiguration - addr bluetooth.Address - adpt *bluetooth.Adapter - device *bluetooth.Device + btAdpt *btAdapter + btDevice *btDevice characteristics map[string]bluetooth.DeviceCharacteristic - connected bool - withoutResponses bool + connected bool + rssi int + + btAdptCreator btAdptCreatorFunc + mutex *sync.Mutex } -// NewAdaptor returns a new Bluetooth LE client adaptor given an address -func NewAdaptor(address string) *Adaptor { - return &Adaptor{ - name: gobot.DefaultName("BLEClient"), - address: address, - AdapterName: "default", - connected: false, - withoutResponses: false, - characteristics: make(map[string]bluetooth.DeviceCharacteristic), +// NewAdaptor returns a new Adaptor given an identifier. The identifier can be the address or the name. +// +// Supported options: +// +// "WithAdaptorDebug" +// "WithAdaptorScanTimeout" +func NewAdaptor(identifier string, opts ...bleAdaptorOptionApplier) *Adaptor { + cfg := bleAdaptorConfiguration{ + scanTimeout: 10 * time.Minute, } + + a := Adaptor{ + name: gobot.DefaultName("BLEClient"), + identifier: identifier, + clientCfg: &cfg, + characteristics: make(map[string]bluetooth.DeviceCharacteristic), + btAdptCreator: newBtAdapter, + mutex: &sync.Mutex{}, + } + + for _, o := range opts { + o.apply(a.clientCfg) + } + + return &a } -// Name returns the name for the adaptor -func (a *Adaptor) Name() string { return a.name } +// WithAdaptorDebug switch on some debug messages. +func WithAdaptorDebug() bleAdaptorDebugOption { + return bleAdaptorDebugOption(true) +} + +// WithAdaptorScanTimeout substitute the default scan timeout of 10 min. +func WithAdaptorScanTimeout(timeout time.Duration) bleAdaptorScanTimeoutOption { + return bleAdaptorScanTimeoutOption(timeout) +} + +// Name returns the name for the adaptor and after the connection is done, the name of the device +func (a *Adaptor) Name() string { + if a.btDevice != nil { + return a.btDevice.Name() + } + return a.name +} // SetName sets the name for the adaptor func (a *Adaptor) SetName(n string) { a.name = n } -// Address returns the Bluetooth LE address for the adaptor -func (a *Adaptor) Address() string { return a.address } +// Address returns the Bluetooth LE address of the device if connected, otherwise the identifier +func (a *Adaptor) Address() string { + if a.btDevice != nil { + return a.btDevice.Address() + } + + return a.identifier +} + +// RSSI returns the Bluetooth LE RSSI value at the moment of connecting the adaptor +func (a *Adaptor) RSSI() int { return a.rssi } // WithoutResponses sets if the adaptor should expect responses after -// writing characteristics for this device -func (a *Adaptor) WithoutResponses(use bool) { a.withoutResponses = use } +// writing characteristics for this device (has no effect at the moment). +func (a *Adaptor) WithoutResponses(bool) {} -// Connect initiates a connection to the BLE peripheral. Returns true on successful connection. +// Connect initiates a connection to the BLE peripheral. func (a *Adaptor) Connect() error { - bleMutex.Lock() - defer bleMutex.Unlock() + a.mutex.Lock() + defer a.mutex.Unlock() var err error - // enable adaptor - a.adpt, err = getBLEAdapter(a.AdapterName) - if err != nil { - return fmt.Errorf("can't get adapter %s: %w", a.AdapterName, err) - } - // handle address - a.addr.Set(a.Address()) + if a.clientCfg.debug { + fmt.Println("[Connect]: enable adaptor...") + } - // scan for the address - ch := make(chan bluetooth.ScanResult, 1) - err = a.adpt.Scan(func(adapter *bluetooth.Adapter, result bluetooth.ScanResult) { - if result.Address.String() == a.Address() { - if err := a.adpt.StopScan(); err != nil { - panic(err) - } - a.SetName(result.LocalName()) - ch <- result + // for re-connect, the adapter is already known + if a.btAdpt == nil { + a.btAdpt = a.btAdptCreator(bluetooth.DefaultAdapter) + if err := a.btAdpt.Enable(); err != nil { + return fmt.Errorf("can't get adapter default: %w", err) } - }) + } + if a.clientCfg.debug { + fmt.Printf("[Connect]: scan %s for the identifier '%s'...\n", a.clientCfg.scanTimeout, a.identifier) + } + + result, err := a.btAdpt.Scan(a.identifier, a.clientCfg.scanTimeout) if err != nil { return err } - // wait to connect to peripheral device - result := <-ch - a.device, err = a.adpt.Connect(result.Address, bluetooth.ConnectionParams{}) + if a.clientCfg.debug { + fmt.Printf("[Connect]: connect to peripheral device with address %s...\n", result.Address) + } + + dev, err := a.btAdpt.Connect(result.Address, result.LocalName()) if err != nil { return err } - // get all services/characteristics - srvcs, err := a.device.DiscoverServices(nil) + a.rssi = int(result.RSSI) + a.btDevice = dev + + if a.clientCfg.debug { + fmt.Println("[Connect]: get all services/characteristics...") + } + services, err := a.btDevice.DiscoverServices(nil) if err != nil { return err } - for _, srvc := range srvcs { - chars, err := srvc.DiscoverCharacteristics(nil) + for _, service := range services { + if a.clientCfg.debug { + fmt.Printf("[Connect]: service found: %s\n", service) + } + chars, err := service.DiscoverCharacteristics(nil) if err != nil { log.Println(err) continue } for _, char := range chars { + if a.clientCfg.debug { + fmt.Printf("[Connect]: characteristic found: %s\n", char) + } a.characteristics[char.UUID().String()] = char } } + if a.clientCfg.debug { + fmt.Println("[Connect]: connected") + } a.connected = true return nil } // Reconnect attempts to reconnect to the BLE peripheral. If it has an active connection // it will first close that connection and then establish a new connection. -// Returns true on Successful reconnection func (a *Adaptor) Reconnect() error { if a.connected { if err := a.Disconnect(); err != nil { @@ -126,10 +179,17 @@ func (a *Adaptor) Reconnect() error { return a.Connect() } -// Disconnect terminates the connection to the BLE peripheral. Returns true on successful disconnect. +// Disconnect terminates the connection to the BLE peripheral. func (a *Adaptor) Disconnect() error { - err := a.device.Disconnect() + if a.clientCfg.debug { + fmt.Println("[Disconnect]: disconnect...") + } + err := a.btDevice.Disconnect() time.Sleep(500 * time.Millisecond) + a.connected = false + if a.clientCfg.debug { + fmt.Println("[Disconnect]: disconnected") + } return err } @@ -197,18 +257,3 @@ func (a *Adaptor) Subscribe(cUUID string, f func([]byte, error)) error { return fmt.Errorf("Unknown characteristic: %s", cUUID) } - -// getBLEAdapter is singleton for bluetooth adapter connection -func getBLEAdapter(impl string) (*bluetooth.Adapter, error) { //nolint:unparam // TODO: impl is unused, maybe an error - if currentAdapter != nil { - return currentAdapter, nil - } - - currentAdapter = bluetooth.DefaultAdapter - err := currentAdapter.Enable() - if err != nil { - return nil, err - } - - return currentAdapter, nil -} diff --git a/platforms/bleclient/ble_client_adaptor_options.go b/platforms/bleclient/ble_client_adaptor_options.go new file mode 100644 index 000000000..0e1650834 --- /dev/null +++ b/platforms/bleclient/ble_client_adaptor_options.go @@ -0,0 +1,30 @@ +package bleclient + +import "time" + +// bleClientAdaptorOptionApplier needs to be implemented by each configurable option type +type bleClientAdaptorOptionApplier interface { + apply(cfg *bleClientAdaptorConfiguration) +} + +// bleClientAdaptorDebug is the type for applying the debug switch on or off. +type bleClientAdaptorDebugOption bool + +// bleClientAdaptorScanTimeoutOption is the type for applying another timeout than the default 10 min. +type bleClientAdaptorScanTimeoutOption time.Duration + +func (o bleClientAdaptorDebugOption) String() string { + return "debug option for BLE client adaptors" +} + +func (o bleClientAdaptorScanTimeoutOption) String() string { + return "scan timeout option for BLE client adaptors" +} + +func (o bleClientAdaptorDebugOption) apply(cfg *bleClientAdaptorConfiguration) { + cfg.debug = bool(o) +} + +func (o bleClientAdaptorScanTimeoutOption) apply(cfg *bleClientAdaptorConfiguration) { + cfg.scanTimeout = time.Duration(o) +} diff --git a/platforms/bleclient/ble_client_adaptor_options_test.go b/platforms/bleclient/ble_client_adaptor_options_test.go new file mode 100644 index 000000000..45f147e97 --- /dev/null +++ b/platforms/bleclient/ble_client_adaptor_options_test.go @@ -0,0 +1,27 @@ +package bleclient + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestWithClientAdaptorDebug(t *testing.T) { + // This is a general test, that options are applied by using the TestWithClientAdaptorDebug() option. + // All other configuration options can also be tested by With..(val).apply(cfg). + // arrange & act + a := NewAdaptor("address", WithClientAdaptorDebug()) + // assert + assert.True(t, a.clientCfg.debug) +} + +func TestWithClientAdaptorScanTimeout(t *testing.T) { + // arrange + newTimeout := 2 * time.Second + cfg := &bleClientAdaptorConfiguration{scanTimeout: 10 * time.Second} + // act + WithClientAdaptorScanTimeout(newTimeout).apply(cfg) + // assert + assert.Equal(t, newTimeout, cfg.scanTimeout) +} diff --git a/platforms/bleclient/ble_client_adaptor_test.go b/platforms/bleclient/ble_client_adaptor_test.go index 2364e9aa7..3ca64e7aa 100644 --- a/platforms/bleclient/ble_client_adaptor_test.go +++ b/platforms/bleclient/ble_client_adaptor_test.go @@ -1,10 +1,14 @@ -package bleclient +package ble import ( + "fmt" "strings" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tinygo.org/x/bluetooth" "gobot.io/x/gobot/v2" ) @@ -25,3 +29,218 @@ func TestName(t *testing.T) { a.SetName("awesome") assert.Equal(t, "awesome", a.Name()) } + +func TestConnect(t *testing.T) { + const ( + scanTimeout = 5 * time.Millisecond + deviceName = "hello" + deviceAddress = "11:22:44:AA:BB:CC" + rssi = 56 + ) + tests := map[string]struct { + identifier string + extAdapter *btTestAdaptor + extDevice *btTestDevice + wantAddress string + wantName string + wantErr string + }{ + "connect_by_address": { + identifier: deviceAddress, + extAdapter: &btTestAdaptor{ + deviceAddress: deviceAddress, + rssi: rssi, + payload: &btTestPayload{name: deviceName}, + }, + extDevice: &btTestDevice{}, + wantAddress: deviceAddress, + wantName: deviceName, + }, + "connect_by_name": { + identifier: deviceName, + extAdapter: &btTestAdaptor{ + deviceAddress: deviceAddress, + rssi: rssi, + payload: &btTestPayload{name: deviceName}, + }, + extDevice: &btTestDevice{}, + wantAddress: deviceAddress, + wantName: deviceName, + }, + "error_enable": { + extAdapter: &btTestAdaptor{ + simulateEnableErr: true, + }, + wantName: "BLEClient", + wantErr: "can't get adapter default: enable error", + }, + "error_scan": { + extAdapter: &btTestAdaptor{ + simulateScanErr: true, + }, + wantName: "BLEClient", + wantErr: "scan error", + }, + "error_stop_scan": { + extAdapter: &btTestAdaptor{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + simulateStopScanErr: true, + }, + wantName: "BLEClient", + wantErr: "stop scan error", + }, + "error_timeout_long_delay": { + extAdapter: &btTestAdaptor{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + scanDelay: 2 * scanTimeout, + }, + wantName: "BLEClient", + wantErr: "scan timeout (5ms) elapsed", + }, + "error_timeout_bad_identifier": { + identifier: "bad_identifier", + extAdapter: &btTestAdaptor{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + }, + wantAddress: "bad_identifier", + wantName: "BLEClient", + wantErr: "scan timeout (5ms) elapsed", + }, + "error_connect": { + extAdapter: &btTestAdaptor{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + simulateConnectErr: true, + }, + wantName: "BLEClient", + wantErr: "connect error", + }, + "error_discovery_services": { + identifier: "disco_err", + extAdapter: &btTestAdaptor{ + deviceAddress: deviceAddress, + payload: &btTestPayload{name: "disco_err"}, + }, + extDevice: &btTestDevice{ + simulateDiscoverServicesErr: true, + }, + wantAddress: deviceAddress, + wantName: "disco_err", + wantErr: "discover services error", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a := NewAdaptor(tc.identifier) + btdc := func(_ bluetoothExtDevicer, address, name string) *btDevice { + return &btDevice{extDevice: tc.extDevice, address: address, name: name} + } + btac := func(bluetoothExtAdapterer) *btAdapter { + return &btAdapter{extAdapter: tc.extAdapter, btDeviceCreator: btdc} + } + a.btAdptCreator = btac + a.clientCfg.scanTimeout = scanTimeout + // act + err := a.Connect() + // assert + if tc.wantErr == "" { + require.NoError(t, err) + assert.Equal(t, tc.wantName, a.Name()) + assert.Equal(t, tc.wantAddress, a.Address()) + assert.Equal(t, rssi, a.RSSI()) + } else { + require.ErrorContains(t, err, tc.wantErr) + assert.Contains(t, a.Name(), tc.wantName) + assert.Equal(t, tc.wantAddress, a.Address()) + } + }) + } +} + +type btTestAdaptor struct { + deviceAddress string + rssi int16 + scanDelay time.Duration + payload *btTestPayload + simulateEnableErr bool + simulateScanErr bool + simulateStopScanErr bool + simulateConnectErr bool +} + +func (bta btTestAdaptor) Enable() error { + if bta.simulateEnableErr { + return fmt.Errorf("enable error") + } + + return nil +} + +func (bta btTestAdaptor) Scan(callback func(*bluetooth.Adapter, bluetooth.ScanResult)) error { + if bta.simulateScanErr { + return fmt.Errorf("scan error") + } + + devAddr, err := bluetooth.ParseMAC(bta.deviceAddress) + if err != nil { + // normally this error should not happen in test + return err + } + time.Sleep(bta.scanDelay) + + a := bluetooth.Address{MACAddress: bluetooth.MACAddress{MAC: devAddr}} + r := bluetooth.ScanResult{Address: a, RSSI: bta.rssi, AdvertisementPayload: bta.payload} + callback(nil, r) + + return nil +} + +func (bta btTestAdaptor) StopScan() error { + if bta.simulateStopScanErr { + return fmt.Errorf("stop scan error") + } + + return nil +} + +func (bta btTestAdaptor) Connect(addr bluetooth.Address, params bluetooth.ConnectionParams) (*bluetooth.Device, error) { + if bta.simulateConnectErr { + return nil, fmt.Errorf("connect error") + } + + //nolint:nilnil // for this test we can not return a *bluetooth.Device + return nil, nil +} + +type btTestPayload struct { + name string +} + +func (ptp btTestPayload) LocalName() string { return ptp.name } + +func (btTestPayload) HasServiceUUID(bluetooth.UUID) bool { return true } + +func (btTestPayload) Bytes() []byte { return nil } + +func (btTestPayload) ManufacturerData() map[uint16][]byte { return nil } + +type btTestDevice struct { + simulateDiscoverServicesErr bool +} + +func (btd btTestDevice) DiscoverServices(uuids []bluetooth.UUID) ([]bluetooth.DeviceService, error) { + if btd.simulateDiscoverServicesErr { + return nil, fmt.Errorf("discover services error") + } + + // for this test we can not return any []bluetooth.DeviceService + return nil, nil +} + +func (btd btTestDevice) Disconnect() error { + return nil +} diff --git a/platforms/bleclient/btwrapper.go b/platforms/bleclient/btwrapper.go new file mode 100644 index 000000000..a43c77be6 --- /dev/null +++ b/platforms/bleclient/btwrapper.go @@ -0,0 +1,122 @@ +package bleclient + +import ( + "fmt" + "time" + + "tinygo.org/x/bluetooth" +) + +// bluetoothExtDevicer is the interface usually implemented by bluetooth.Device +type bluetoothExtDevicer interface { + DiscoverServices(uuids []bluetooth.UUID) ([]bluetooth.DeviceService, error) + Disconnect() error +} + +// bluetoothExtAdapterer is the interface usually implemented by bluetooth.Adapter +type bluetoothExtAdapterer interface { + Enable() error + Scan(callback func(*bluetooth.Adapter, bluetooth.ScanResult)) error + StopScan() error + Connect(address bluetooth.Address, params bluetooth.ConnectionParams) (*bluetooth.Device, error) +} + +// btAdptCreatorFunc is just a convenience type, used in the BLE client to ensure testability +type btAdptCreatorFunc func(bluetoothExtAdapterer) *btAdapter + +// btAdapter is the wrapper for an external adapter implementation +type btAdapter struct { + extAdapter bluetoothExtAdapterer + btDeviceCreator func(bluetoothExtDevicer, string, string) *btDevice +} + +// newBtAdapter creates a new wrapper around the given external implementation +func newBtAdapter(a bluetoothExtAdapterer) *btAdapter { + bta := btAdapter{ + extAdapter: a, + btDeviceCreator: newBtDevice, + } + + return &bta +} + +// Enable configures the BLE stack. It must be called before any Bluetooth-related calls (unless otherwise indicated). +// It pass through the function of the external implementation. +func (bta btAdapter) Enable() error { + return bta.extAdapter.Enable() +} + +// 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 (bta btAdapter) StopScan() error { + return bta.extAdapter.StopScan() +} + +// Connect starts a connection attempt to the given peripheral device address. +// +// On Linux and Windows, the IsRandom part of the address is ignored. +func (bta *btAdapter) Connect(address bluetooth.Address, devName string) (*btDevice, error) { + extDev, err := bta.extAdapter.Connect(address, bluetooth.ConnectionParams{}) + if err != nil { + return nil, err + } + + return bta.btDeviceCreator(extDev, address.String(), devName), nil +} + +// Scan starts a BLE scan for the given identifier (address or name). +func (bta *btAdapter) Scan(identifier string, scanTimeout time.Duration) (*bluetooth.ScanResult, error) { + resultChan := make(chan bluetooth.ScanResult, 1) + errChan := make(chan error) + + go func() { + callback := func(_ *bluetooth.Adapter, result bluetooth.ScanResult) { + if result.Address.String() == identifier || result.LocalName() == identifier { + resultChan <- result + } + } + err := bta.extAdapter.Scan(callback) + if err != nil { + errChan <- err + } + }() + + select { + case result := <-resultChan: + if err := bta.StopScan(); err != nil { + return nil, err + } + + return &result, nil + case err := <-errChan: + return nil, err + case <-time.After(scanTimeout): + _ = bta.StopScan() + return nil, fmt.Errorf("scan timeout (%s) elapsed", scanTimeout) + } +} + +// btDevice is the wrapper for an external device implementation +type btDevice struct { + extDevice bluetoothExtDevicer + address string + name string +} + +// newBtDevice creates a new wrapper around the given external implementation +func newBtDevice(d bluetoothExtDevicer, address, name string) *btDevice { + return &btDevice{extDevice: d, address: address, name: name} +} + +func (btd btDevice) Name() string { return btd.name } + +func (btd btDevice) Address() string { return btd.address } + +func (btd btDevice) DiscoverServices(uuids []bluetooth.UUID) ([]bluetooth.DeviceService, error) { + return btd.extDevice.DiscoverServices(uuids) +} + +// Disconnect from the BLE device. This method is non-blocking and does not wait until the connection is fully gone. +func (btd btDevice) Disconnect() error { + return btd.extDevice.Disconnect() +}