diff --git a/platforms/adaptors/pwmpinsadaptoroptions.go b/platforms/adaptors/pwmpinsadaptoroptions.go index 168840470..c9ad49e69 100644 --- a/platforms/adaptors/pwmpinsadaptoroptions.go +++ b/platforms/adaptors/pwmpinsadaptoroptions.go @@ -2,7 +2,7 @@ package adaptors import "time" -// pwmPinOptionApplier needs to be implemented by each configurable option type +// PwmPinsOptionApplier needs to be implemented by each configurable option type type PwmPinsOptionApplier interface { apply(cfg *pwmPinsConfiguration) } diff --git a/platforms/ble/ble_client_adaptor.go b/platforms/ble/ble_client_adaptor.go index 8273e1c94..0c86b346f 100644 --- a/platforms/ble/ble_client_adaptor.go +++ b/platforms/ble/ble_client_adaptor.go @@ -11,11 +11,6 @@ import ( "gobot.io/x/gobot/v2" ) -var ( - currentAdapter *bluetooth.Adapter - bleMutex sync.Mutex -) - // BLEConnector is the interface that a BLE ClientAdaptor must implement type BLEConnector interface { gobot.Adaptor @@ -30,107 +25,165 @@ type BLEConnector interface { WithoutResponses(use bool) } +type bleClientAdaptorConfiguration struct { + scanTimeout time.Duration + debug bool +} + // ClientAdaptor represents a Client Connection to a BLE Peripheral type ClientAdaptor struct { - name string - address string - AdapterName string + name string + identifier string + clientCfg *bleClientAdaptorConfiguration - 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 } -// NewClientAdaptor returns a new ClientAdaptor given an address -func NewClientAdaptor(address string) *ClientAdaptor { - return &ClientAdaptor{ - name: gobot.DefaultName("BLEClient"), - address: address, - AdapterName: "default", - connected: false, - withoutResponses: false, - characteristics: make(map[string]bluetooth.DeviceCharacteristic), +// NewClientAdaptor returns a new ClientAdaptor given an identifier. The identifier can be the address or the name. +// +// Supported options: +// +// "WithClientAdaptorDebug" +// "WithClientAdaptorScanTimeout" +func NewClientAdaptor(identifier string, opts ...bleClientAdaptorOptionApplier) *ClientAdaptor { + cfg := bleClientAdaptorConfiguration{ + scanTimeout: 10 * time.Minute, } + + b := ClientAdaptor{ + 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(b.clientCfg) + } + + return &b +} + +// WithClientAdaptorDebug switch on some debug messages. +func WithClientAdaptorDebug() bleClientAdaptorDebugOption { + return bleClientAdaptorDebugOption(true) +} + +// WithClientAdaptorScanTimeout substitute the default scan timeout of 10 min. +func WithClientAdaptorScanTimeout(timeout time.Duration) bleClientAdaptorScanTimeoutOption { + return bleClientAdaptorScanTimeoutOption(timeout) } -// Name returns the name for the adaptor -func (b *ClientAdaptor) Name() string { return b.name } +// Name returns the name for the adaptor and after the connection is done, the name of the device +func (b *ClientAdaptor) Name() string { + if b.btDevice != nil { + return b.btDevice.Name() + } + return b.name +} // SetName sets the name for the adaptor func (b *ClientAdaptor) SetName(n string) { b.name = n } -// Address returns the Bluetooth LE address for the adaptor -func (b *ClientAdaptor) Address() string { return b.address } +// Address returns the Bluetooth LE address of the device if connected, otherwise the identifier +func (b *ClientAdaptor) Address() string { + if b.btDevice != nil { + return b.btDevice.Address() + } + + return b.identifier +} + +// RSSI returns the Bluetooth LE RSSI value at the moment of connecting the adaptor +func (b *ClientAdaptor) RSSI() int { return b.rssi } // WithoutResponses sets if the adaptor should expect responses after -// writing characteristics for this device -func (b *ClientAdaptor) WithoutResponses(use bool) { b.withoutResponses = use } +// writing characteristics for this device (has no effect at the moment). +func (b *ClientAdaptor) WithoutResponses(bool) {} -// Connect initiates a connection to the BLE peripheral. Returns true on successful connection. +// Connect initiates a connection to the BLE peripheral. func (b *ClientAdaptor) Connect() error { - bleMutex.Lock() - defer bleMutex.Unlock() + b.mutex.Lock() + defer b.mutex.Unlock() var err error - // enable adaptor - b.adpt, err = getBLEAdapter(b.AdapterName) - if err != nil { - return fmt.Errorf("can't get adapter %s: %w", b.AdapterName, err) - } - // handle address - b.addr.Set(b.Address()) + if b.clientCfg.debug { + fmt.Println("[Connect]: enable adaptor...") + } - // scan for the address - ch := make(chan bluetooth.ScanResult, 1) - err = b.adpt.Scan(func(adapter *bluetooth.Adapter, result bluetooth.ScanResult) { - if result.Address.String() == b.Address() { - if err := b.adpt.StopScan(); err != nil { - panic(err) - } - b.SetName(result.LocalName()) - ch <- result + // for re-connect, the adapter is already known + if b.btAdpt == nil { + b.btAdpt = b.btAdptCreator(bluetooth.DefaultAdapter) + if err := b.btAdpt.Enable(); err != nil { + return fmt.Errorf("can't get adapter default: %w", err) } - }) + } + if b.clientCfg.debug { + fmt.Printf("[Connect]: scan %s for the identifier '%s'...\n", b.clientCfg.scanTimeout, b.identifier) + } + + result, err := b.btAdpt.Scan(b.identifier, b.clientCfg.scanTimeout) if err != nil { return err } - // wait to connect to peripheral device - result := <-ch - b.device, err = b.adpt.Connect(result.Address, bluetooth.ConnectionParams{}) + if b.clientCfg.debug { + fmt.Printf("[Connect]: connect to peripheral device with address %s...\n", result.Address) + } + + dev, err := b.btAdpt.Connect(result.Address, result.LocalName()) if err != nil { return err } - // get all services/characteristics - srvcs, err := b.device.DiscoverServices(nil) + b.rssi = int(result.RSSI) + b.btDevice = dev + + if b.clientCfg.debug { + fmt.Println("[Connect]: get all services/characteristics...") + } + services, err := b.btDevice.DiscoverServices(nil) if err != nil { return err } - for _, srvc := range srvcs { - chars, err := srvc.DiscoverCharacteristics(nil) + for _, service := range services { + if b.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 b.clientCfg.debug { + fmt.Printf("[Connect]: characteristic found: %s\n", char) + } b.characteristics[char.UUID().String()] = char } } + if b.clientCfg.debug { + fmt.Println("[Connect]: connected") + } b.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 (b *ClientAdaptor) Reconnect() error { if b.connected { if err := b.Disconnect(); err != nil { @@ -140,10 +193,17 @@ func (b *ClientAdaptor) Reconnect() error { return b.Connect() } -// Disconnect terminates the connection to the BLE peripheral. Returns true on successful disconnect. +// Disconnect terminates the connection to the BLE peripheral. func (b *ClientAdaptor) Disconnect() error { - err := b.device.Disconnect() + if b.clientCfg.debug { + fmt.Println("[Disconnect]: disconnect...") + } + err := b.btDevice.Disconnect() time.Sleep(500 * time.Millisecond) + b.connected = false + if b.clientCfg.debug { + fmt.Println("[Disconnect]: disconnected") + } return err } @@ -211,18 +271,3 @@ func (b *ClientAdaptor) 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/ble/ble_client_adaptor_options.go b/platforms/ble/ble_client_adaptor_options.go new file mode 100644 index 000000000..d6bf4ca5d --- /dev/null +++ b/platforms/ble/ble_client_adaptor_options.go @@ -0,0 +1,30 @@ +package ble + +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/ble/ble_client_adaptor_options_test.go b/platforms/ble/ble_client_adaptor_options_test.go new file mode 100644 index 000000000..9d70b1eb7 --- /dev/null +++ b/platforms/ble/ble_client_adaptor_options_test.go @@ -0,0 +1,27 @@ +package ble + +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 := NewClientAdaptor("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/ble/ble_client_adaptor_test.go b/platforms/ble/ble_client_adaptor_test.go index 0862405e8..408be6091 100644 --- a/platforms/ble/ble_client_adaptor_test.go +++ b/platforms/ble/ble_client_adaptor_test.go @@ -1,24 +1,246 @@ 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" ) -var _ gobot.Adaptor = (*ClientAdaptor)(nil) +var ( + _ gobot.Adaptor = (*ClientAdaptor)(nil) + _ BLEConnector = (*ClientAdaptor)(nil) +) -func TestBLEClientAdaptor(t *testing.T) { +func TestNewClientAdaptor(t *testing.T) { a := NewClientAdaptor("D7:99:5A:26:EC:38") assert.Equal(t, "D7:99:5A:26:EC:38", a.Address()) assert.True(t, strings.HasPrefix(a.Name(), "BLEClient")) } -func TestBLEClientAdaptorName(t *testing.T) { +func TestClientAdaptorName(t *testing.T) { a := NewClientAdaptor("D7:99:5A:26:EC:38") a.SetName("awesome") assert.Equal(t, "awesome", a.Name()) } + +func TestClientAdaptorConnect(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 := NewClientAdaptor(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/ble/btwrapper.go b/platforms/ble/btwrapper.go new file mode 100644 index 000000000..811d27164 --- /dev/null +++ b/platforms/ble/btwrapper.go @@ -0,0 +1,122 @@ +package ble + +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() +}