From 9430005b82d6f607cf9f72d054a553160628ad5e Mon Sep 17 00:00:00 2001 From: Thomas Kohler Date: Sun, 11 Feb 2024 17:01:24 +0100 Subject: [PATCH] ble(client): add scan timout (#1051) --- examples/bleclient_battery.go | 2 +- examples/bleclient_device_info.go | 3 +- examples/bleclient_generic_access.go | 3 +- platforms/bleclient/ble_client_adaptor.go | 294 ++++++++------ .../bleclient/ble_client_adaptor_options.go | 30 ++ .../ble_client_adaptor_options_test.go | 27 ++ .../bleclient/ble_client_adaptor_test.go | 380 ++++++++++++++++++ platforms/bleclient/btwrapper.go | 154 +++++++ platforms/bleclient/helper_test.go | 118 ++++++ platforms/bleclient/uuid.go | 38 +- platforms/bleclient/uuid_test.go | 58 +++ 11 files changed, 967 insertions(+), 140 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 create mode 100644 platforms/bleclient/helper_test.go create mode 100644 platforms/bleclient/uuid_test.go diff --git a/examples/bleclient_battery.go b/examples/bleclient_battery.go index f0a17995..b84d0e62 100644 --- a/examples/bleclient_battery.go +++ b/examples/bleclient_battery.go @@ -26,7 +26,7 @@ import ( ) func main() { - bleAdaptor := bleclient.NewAdaptor(os.Args[1]) + bleAdaptor := bleclient.NewAdaptor(os.Args[1], bleclient.WithScanTimeout(30*time.Second)) battery := ble.NewBatteryDriver(bleAdaptor) work := func() { diff --git a/examples/bleclient_device_info.go b/examples/bleclient_device_info.go index 69f8c997..0b8adfce 100644 --- a/examples/bleclient_device_info.go +++ b/examples/bleclient_device_info.go @@ -18,6 +18,7 @@ package main import ( "fmt" "os" + "time" "gobot.io/x/gobot/v2" "gobot.io/x/gobot/v2/drivers/ble" @@ -25,7 +26,7 @@ import ( ) func main() { - bleAdaptor := bleclient.NewAdaptor(os.Args[1]) + bleAdaptor := bleclient.NewAdaptor(os.Args[1], bleclient.WithScanTimeout(30*time.Second), bleclient.WithDebug()) info := ble.NewDeviceInformationDriver(bleAdaptor) work := func() { diff --git a/examples/bleclient_generic_access.go b/examples/bleclient_generic_access.go index 47e7842f..400f4b4a 100644 --- a/examples/bleclient_generic_access.go +++ b/examples/bleclient_generic_access.go @@ -18,6 +18,7 @@ package main import ( "fmt" "os" + "time" "gobot.io/x/gobot/v2" "gobot.io/x/gobot/v2/drivers/ble" @@ -25,7 +26,7 @@ import ( ) func main() { - bleAdaptor := bleclient.NewAdaptor(os.Args[1]) + bleAdaptor := bleclient.NewAdaptor(os.Args[1], bleclient.WithScanTimeout(30*time.Second), bleclient.WithDebug()) access := ble.NewGenericAccessDriver(bleAdaptor) work := func() { diff --git a/platforms/bleclient/ble_client_adaptor.go b/platforms/bleclient/ble_client_adaptor.go index ffc09f15..c6e336f8 100644 --- a/platforms/bleclient/ble_client_adaptor.go +++ b/platforms/bleclient/ble_client_adaptor.go @@ -11,112 +11,168 @@ import ( "gobot.io/x/gobot/v2" ) -var ( - currentAdapter *bluetooth.Adapter - bleMutex sync.Mutex -) +type configuration struct { + scanTimeout time.Duration + sleepAfterDisconnect 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 + cfg *configuration - addr bluetooth.Address - adpt *bluetooth.Adapter - device *bluetooth.Device - characteristics map[string]bluetooth.DeviceCharacteristic + btAdpt *btAdapter + btDevice *btDevice + characteristics map[string]bluetoothExtCharacteristicer - 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 ...optionApplier) *Adaptor { + cfg := configuration{ + scanTimeout: 10 * time.Minute, + sleepAfterDisconnect: 500 * time.Millisecond, } + + a := Adaptor{ + name: gobot.DefaultName("BLEClient"), + identifier: identifier, + cfg: &cfg, + characteristics: make(map[string]bluetoothExtCharacteristicer), + btAdptCreator: newBtAdapter, + mutex: &sync.Mutex{}, + } + + for _, o := range opts { + o.apply(a.cfg) + } + + return &a } -// Name returns the name for the adaptor -func (a *Adaptor) Name() string { return a.name } +// WithDebug switch on some debug messages. +func WithDebug() debugOption { + return debugOption(true) +} + +// WithScanTimeout substitute the default scan timeout of 10 min. +func WithScanTimeout(timeout time.Duration) scanTimeoutOption { + return scanTimeoutOption(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) + + if a.cfg.debug { + fmt.Println("[Connect]: enable adaptor...") } - // handle address - a.addr.Set(a.Address()) - - // 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, a.cfg.debug) + if err := a.btAdpt.enable(); err != nil { + return fmt.Errorf("can't get adapter default: %w", err) } - }) + } + if a.cfg.debug { + fmt.Printf("[Connect]: scan %s for the identifier '%s'...\n", a.cfg.scanTimeout, a.identifier) + } + + result, err := a.btAdpt.scan(a.identifier, a.cfg.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.cfg.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.cfg.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.cfg.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 { - a.characteristics[char.UUID().String()] = char + if a.cfg.debug { + fmt.Printf("[Connect]: characteristic found: %s\n", char) + } + c := char // to prevent implicit memory aliasing in for loop, before go 1.22 + a.characteristics[char.UUID().String()] = &c } } + if a.cfg.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 +182,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() - time.Sleep(500 * time.Millisecond) + if a.cfg.debug { + fmt.Println("[Disconnect]: disconnect...") + } + err := a.btDevice.disconnect() + time.Sleep(a.cfg.sleepAfterDisconnect) + a.connected = false + if a.cfg.debug { + fmt.Println("[Disconnect]: disconnected") + } return err } @@ -138,74 +201,59 @@ func (a *Adaptor) Finalize() error { return a.Disconnect() } -// ReadCharacteristic returns bytes from the BLE device for the -// requested characteristic uuid +// ReadCharacteristic returns bytes from the BLE device for the requested characteristic UUID. +// The UUID can be given as 16-bit or 128-bit (with or without dashes) value. func (a *Adaptor) ReadCharacteristic(cUUID string) ([]byte, error) { if !a.connected { - return nil, fmt.Errorf("Cannot read from BLE device until connected") + return nil, fmt.Errorf("cannot read from BLE device until connected") } - cUUID = convertUUID(cUUID) - - if char, ok := a.characteristics[cUUID]; ok { - buf := make([]byte, 255) - n, err := char.Read(buf) - if err != nil { - return nil, err - } - return buf[:n], nil - } - - return nil, fmt.Errorf("Unknown characteristic: %s", cUUID) -} - -// WriteCharacteristic writes bytes to the BLE device for the -// requested service and characteristic -func (a *Adaptor) WriteCharacteristic(cUUID string, data []byte) error { - if !a.connected { - return fmt.Errorf("Cannot write to BLE device until connected") - } - - cUUID = convertUUID(cUUID) - - if char, ok := a.characteristics[cUUID]; ok { - _, err := char.WriteWithoutResponse(data) - if err != nil { - return err - } - return nil - } - - return fmt.Errorf("Unknown characteristic: %s", cUUID) -} - -// Subscribe subscribes to notifications from the BLE device for the -// requested service and characteristic -func (a *Adaptor) Subscribe(cUUID string, f func([]byte)) error { - if !a.connected { - return fmt.Errorf("Cannot subscribe to BLE device until connected") - } - - cUUID = convertUUID(cUUID) - - if char, ok := a.characteristics[cUUID]; ok { - return char.EnableNotifications(f) - } - - 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() + cUUID, err := convertUUID(cUUID) if err != nil { return nil, err } - return currentAdapter, nil + if chara, ok := a.characteristics[cUUID]; ok { + return readFromCharacteristic(chara) + } + + return nil, fmt.Errorf("unknown characteristic: %s", cUUID) +} + +// WriteCharacteristic writes bytes to the BLE device for the requested characteristic UUID. +// The UUID can be given as 16-bit or 128-bit (with or without dashes) value. +func (a *Adaptor) WriteCharacteristic(cUUID string, data []byte) error { + if !a.connected { + return fmt.Errorf("cannot write to BLE device until connected") + } + + cUUID, err := convertUUID(cUUID) + if err != nil { + return err + } + + if chara, ok := a.characteristics[cUUID]; ok { + return writeToCharacteristicWithoutResponse(chara, data) + } + + return fmt.Errorf("unknown characteristic: %s", cUUID) +} + +// Subscribe subscribes to notifications from the BLE device for the requested characteristic UUID. +// The UUID can be given as 16-bit or 128-bit (with or without dashes) value. +func (a *Adaptor) Subscribe(cUUID string, f func(data []byte)) error { + if !a.connected { + return fmt.Errorf("cannot subscribe to BLE device until connected") + } + + cUUID, err := convertUUID(cUUID) + if err != nil { + return err + } + + if chara, ok := a.characteristics[cUUID]; ok { + return enableNotificationsForCharacteristic(chara, f) + } + + return fmt.Errorf("unknown characteristic: %s", cUUID) } diff --git a/platforms/bleclient/ble_client_adaptor_options.go b/platforms/bleclient/ble_client_adaptor_options.go new file mode 100644 index 00000000..91e92478 --- /dev/null +++ b/platforms/bleclient/ble_client_adaptor_options.go @@ -0,0 +1,30 @@ +package bleclient + +import "time" + +// optionApplier needs to be implemented by each configurable option type +type optionApplier interface { + apply(cfg *configuration) +} + +// debugOption is the type for applying the debug switch on or off. +type debugOption bool + +// scanTimeoutOption is the type for applying another timeout than the default 10 min. +type scanTimeoutOption time.Duration + +func (o debugOption) String() string { + return "debug option for BLE client adaptors" +} + +func (o scanTimeoutOption) String() string { + return "scan timeout option for BLE client adaptors" +} + +func (o debugOption) apply(cfg *configuration) { + cfg.debug = bool(o) +} + +func (o scanTimeoutOption) apply(cfg *configuration) { + 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 00000000..462ae21e --- /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 TestWithDebug(t *testing.T) { + // This is a general test, that options are applied by using the WithDebug() option. + // All other configuration options can also be tested by With..(val).apply(cfg). + // arrange & act + a := NewAdaptor("address", WithDebug()) + // assert + assert.True(t, a.cfg.debug) +} + +func TestWithScanTimeout(t *testing.T) { + // arrange + newTimeout := 2 * time.Second + cfg := &configuration{scanTimeout: 10 * time.Second} + // act + WithScanTimeout(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 2364e9aa..614c6a04 100644 --- a/platforms/bleclient/ble_client_adaptor_test.go +++ b/platforms/bleclient/ble_client_adaptor_test.go @@ -3,8 +3,10 @@ package bleclient import ( "strings" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gobot.io/x/gobot/v2" ) @@ -25,3 +27,381 @@ 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 *btTestAdapter + extDevice *btTestDevice + wantAddress string + wantName string + wantErr string + }{ + "connect_by_address": { + identifier: deviceAddress, + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + rssi: rssi, + payload: &btTestPayload{name: deviceName}, + }, + extDevice: &btTestDevice{}, + wantAddress: deviceAddress, + wantName: deviceName, + }, + "connect_by_name": { + identifier: deviceName, + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + rssi: rssi, + payload: &btTestPayload{name: deviceName}, + }, + extDevice: &btTestDevice{}, + wantAddress: deviceAddress, + wantName: deviceName, + }, + "error_enable": { + extAdapter: &btTestAdapter{ + simulateEnableErr: true, + }, + wantName: "BLEClient", + wantErr: "can't get adapter default: adapter enable error", + }, + "error_scan": { + extAdapter: &btTestAdapter{ + simulateScanErr: true, + }, + wantName: "BLEClient", + wantErr: "scan error", + }, + "error_stop_scan": { + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + simulateStopScanErr: true, + }, + wantName: "BLEClient", + wantErr: "stop scan error", + }, + "error_timeout_long_delay": { + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + scanDelay: 2 * scanTimeout, + }, + wantName: "BLEClient", + wantErr: "scan timeout (5ms) elapsed", + }, + "error_timeout_bad_identifier": { + identifier: "bad_identifier", + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + }, + wantAddress: "bad_identifier", + wantName: "BLEClient", + wantErr: "scan timeout (5ms) elapsed", + }, + "error_connect": { + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + payload: &btTestPayload{}, + simulateConnectErr: true, + }, + wantName: "BLEClient", + wantErr: "adapter connect error", + }, + "error_discovery_services": { + identifier: "disco_err", + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + payload: &btTestPayload{name: "disco_err"}, + }, + extDevice: &btTestDevice{ + simulateDiscoverServicesErr: true, + }, + wantAddress: deviceAddress, + wantName: "disco_err", + wantErr: "device 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, devAddress: address, devName: name} + } + btac := func(bluetoothExtAdapterer, bool) *btAdapter { + return &btAdapter{extAdapter: tc.extAdapter, btDeviceCreator: btdc} + } + a.btAdptCreator = btac + a.cfg.scanTimeout = scanTimeout // to speed up test + // 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()) + assert.True(t, a.connected) + } else { + require.ErrorContains(t, err, tc.wantErr) + assert.Contains(t, a.Name(), tc.wantName) + assert.Equal(t, tc.wantAddress, a.Address()) + assert.False(t, a.connected) + } + }) + } +} + +func TestReconnect(t *testing.T) { + const ( + scanTimeout = 5 * time.Millisecond + deviceName = "hello" + deviceAddress = "11:22:44:AA:BB:CC" + rssi = 56 + ) + tests := map[string]struct { + extAdapter *btTestAdapter + extDevice *btTestDevice + wasConnected bool + wantErr string + }{ + "reconnect_not_connected": { + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + rssi: rssi, + payload: &btTestPayload{name: deviceName}, + }, + extDevice: &btTestDevice{}, + }, + "reconnect_was_connected": { + extAdapter: &btTestAdapter{ + deviceAddress: deviceAddress, + rssi: rssi, + payload: &btTestPayload{name: deviceName}, + }, + extDevice: &btTestDevice{}, + wasConnected: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a := NewAdaptor(deviceAddress) + btdc := func(_ bluetoothExtDevicer, address, name string) *btDevice { + return &btDevice{extDevice: tc.extDevice, devAddress: address, devName: name} + } + a.btAdpt = &btAdapter{extAdapter: tc.extAdapter, btDeviceCreator: btdc} + a.cfg.scanTimeout = scanTimeout // to speed up test in case of errors + a.cfg.sleepAfterDisconnect = 0 // to speed up test + if tc.wasConnected { + a.btDevice = btdc(nil, "", "") + a.connected = tc.wasConnected + } + // act + err := a.Reconnect() + // assert + if tc.wantErr == "" { + require.NoError(t, err) + assert.Equal(t, rssi, a.RSSI()) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + assert.True(t, a.connected) + }) + } +} + +func TestFinalize(t *testing.T) { + // this also tests Disconnect() + tests := map[string]struct { + extDevice *btTestDevice + wantErr string + }{ + "disconnect": { + extDevice: &btTestDevice{}, + }, + "error_disconnect": { + extDevice: &btTestDevice{ + simulateDisconnectErr: true, + }, + wantErr: "device disconnect error", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a := NewAdaptor("") + a.cfg.sleepAfterDisconnect = 0 // to speed up test + a.btDevice = &btDevice{extDevice: tc.extDevice} + // act + err := a.Finalize() + // assert + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + assert.False(t, a.connected) + }) + } +} + +func TestReadCharacteristic(t *testing.T) { + const uuid = "00001234-0000-1000-8000-00805f9b34fb" + tests := map[string]struct { + inUUID string + chara *btTestChara + notConnected bool + want []byte + wantErr string + }{ + "read_ok": { + inUUID: uuid, + chara: &btTestChara{readData: []byte{1, 2, 3}}, + want: []byte{1, 2, 3}, + }, + "error_not_connected": { + notConnected: true, + wantErr: "cannot read from BLE device until connected", + }, + "error_bad_chara": { + inUUID: "gag1", + wantErr: "'gag1' is not a valid 16-bit Bluetooth UUID", + }, + "error_unknown_chara": { + inUUID: uuid, + wantErr: "unknown characteristic: 00001234-0000-1000-8000-00805f9b34fb", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a := NewAdaptor("") + if tc.chara != nil { + a.characteristics[uuid] = tc.chara + } + a.connected = !tc.notConnected + // act + got, err := a.ReadCharacteristic(tc.inUUID) + // assert + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + assert.Equal(t, tc.want, got) + }) + } +} + +func TestWriteCharacteristic(t *testing.T) { + const uuid = "00004321-0000-1000-8000-00805f9b34fb" + tests := map[string]struct { + inUUID string + inData []byte + notConnected bool + chara *btTestChara + want []byte + wantErr string + }{ + "write_ok": { + inUUID: uuid, + inData: []byte{3, 2, 1}, + chara: &btTestChara{}, + want: []byte{3, 2, 1}, + }, + "error_not_connected": { + notConnected: true, + wantErr: "cannot write to BLE device until connected", + }, + "error_bad_chara": { + inUUID: "gag2", + wantErr: "'gag2' is not a valid 16-bit Bluetooth UUID", + }, + "error_unknown_chara": { + inUUID: uuid, + wantErr: "unknown characteristic: 00004321-0000-1000-8000-00805f9b34fb", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a := NewAdaptor("") + if tc.chara != nil { + a.characteristics[uuid] = tc.chara + } + a.connected = !tc.notConnected + // act + err := a.WriteCharacteristic(tc.inUUID, tc.inData) + // assert + if tc.wantErr == "" { + require.NoError(t, err) + assert.Equal(t, tc.want, tc.chara.writtenData) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + }) + } +} + +func TestSubscribe(t *testing.T) { + const uuid = "00004321-0000-1000-8000-00805f9b34fb" + tests := map[string]struct { + inUUID string + notConnected bool + chara *btTestChara + want []byte + wantErr string + }{ + "subscribe_ok": { + inUUID: uuid, + chara: &btTestChara{}, + want: []byte{3, 4, 5}, + }, + "error_not_connected": { + notConnected: true, + wantErr: "cannot subscribe to BLE device until connected", + }, + "error_bad_chara": { + inUUID: "gag2", + wantErr: "'gag2' is not a valid 16-bit Bluetooth UUID", + }, + "error_unknown_chara": { + inUUID: uuid, + wantErr: "unknown characteristic: 00004321-0000-1000-8000-00805f9b34fb", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a := NewAdaptor("") + if tc.chara != nil { + a.characteristics[uuid] = tc.chara + } + a.connected = !tc.notConnected + var got []byte + notificationFunc := func(data []byte) { + got = append(got, data...) + } + // act + err := a.Subscribe(tc.inUUID, notificationFunc) + // assert + if tc.wantErr == "" { + require.NoError(t, err) + tc.chara.notificationFunc([]byte{3, 4, 5}) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/platforms/bleclient/btwrapper.go b/platforms/bleclient/btwrapper.go new file mode 100644 index 00000000..011526a3 --- /dev/null +++ b/platforms/bleclient/btwrapper.go @@ -0,0 +1,154 @@ +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) +} + +type bluetoothExtCharacteristicer interface { + Read(data []byte) (int, error) + WriteWithoutResponse(p []byte) (n int, err error) + EnableNotifications(callback func(buf []byte)) error +} + +// btAdptCreatorFunc is just a convenience type, used in the BLE client to ensure testability +type btAdptCreatorFunc func(bluetoothExtAdapterer, bool) *btAdapter + +// btAdapter is the wrapper for an external adapter implementation +type btAdapter struct { + extAdapter bluetoothExtAdapterer + btDeviceCreator func(bluetoothExtDevicer, string, string) *btDevice + debug bool +} + +// newBtAdapter creates a new wrapper around the given external implementation +func newBtAdapter(a bluetoothExtAdapterer, debug bool) *btAdapter { + bta := btAdapter{ + extAdapter: a, + btDeviceCreator: newBtDevice, + debug: debug, + } + + 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 bta.debug { + fmt.Printf("[scan result]: address: '%s', rssi: %d, name: '%s', manufacturer: %v\n", + result.Address, result.RSSI, result.LocalName(), result.ManufacturerData()) + } + 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 + devAddress string + devName string +} + +// newBtDevice creates a new wrapper around the given external implementation +func newBtDevice(d bluetoothExtDevicer, address, name string) *btDevice { + return &btDevice{extDevice: d, devAddress: address, devName: name} +} + +func (btd *btDevice) name() string { return btd.devName } + +func (btd *btDevice) address() string { return btd.devAddress } + +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() +} + +func readFromCharacteristic(chara bluetoothExtCharacteristicer) ([]byte, error) { + buf := make([]byte, 255) + n, err := chara.Read(buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +func writeToCharacteristicWithoutResponse(chara bluetoothExtCharacteristicer, data []byte) error { + if _, err := chara.WriteWithoutResponse(data); err != nil { + return err + } + return nil +} + +func enableNotificationsForCharacteristic(chara bluetoothExtCharacteristicer, f func(data []byte)) error { + return chara.EnableNotifications(f) +} diff --git a/platforms/bleclient/helper_test.go b/platforms/bleclient/helper_test.go new file mode 100644 index 00000000..459d9b41 --- /dev/null +++ b/platforms/bleclient/helper_test.go @@ -0,0 +1,118 @@ +package bleclient + +import ( + "fmt" + "time" + + "tinygo.org/x/bluetooth" +) + +type btTestAdapter struct { + deviceAddress string + rssi int16 + scanDelay time.Duration + payload *btTestPayload + simulateEnableErr bool + simulateScanErr bool + simulateStopScanErr bool + simulateConnectErr bool +} + +func (bta *btTestAdapter) Enable() error { + if bta.simulateEnableErr { + return fmt.Errorf("adapter enable error") + } + + return nil +} + +func (bta *btTestAdapter) Scan(callback func(*bluetooth.Adapter, bluetooth.ScanResult)) error { + if bta.simulateScanErr { + return fmt.Errorf("adapter 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 *btTestAdapter) StopScan() error { + if bta.simulateStopScanErr { + return fmt.Errorf("adapter stop scan error") + } + + return nil +} + +func (bta *btTestAdapter) Connect(_ bluetooth.Address, _ bluetooth.ConnectionParams) (*bluetooth.Device, error) { + if bta.simulateConnectErr { + return nil, fmt.Errorf("adapter 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 + simulateDisconnectErr bool +} + +func (btd *btTestDevice) DiscoverServices(_ []bluetooth.UUID) ([]bluetooth.DeviceService, error) { + if btd.simulateDiscoverServicesErr { + return nil, fmt.Errorf("device discover services error") + } + + // for this test we can not return any []bluetooth.DeviceService + return nil, nil +} + +func (btd *btTestDevice) Disconnect() error { + if btd.simulateDisconnectErr { + return fmt.Errorf("device disconnect error") + } + + return nil +} + +type btTestChara struct { + readData []byte + writtenData []byte + notificationFunc func(buf []byte) +} + +func (btc *btTestChara) Read(data []byte) (int, error) { + copy(data, btc.readData) + return len(btc.readData), nil +} + +func (btc *btTestChara) WriteWithoutResponse(data []byte) (int, error) { + btc.writtenData = append(btc.writtenData, data...) + return len(data), nil +} + +func (btc *btTestChara) EnableNotifications(callback func(buf []byte)) error { + btc.notificationFunc = callback + return nil +} diff --git a/platforms/bleclient/uuid.go b/platforms/bleclient/uuid.go index bf120fbe..ddec347a 100644 --- a/platforms/bleclient/uuid.go +++ b/platforms/bleclient/uuid.go @@ -3,28 +3,38 @@ package bleclient import ( "fmt" "strconv" + "strings" "tinygo.org/x/bluetooth" ) -func convertUUID(cUUID string) string { +// convertUUID creates a common 128 bit UUID xxxxyyyy-0000-1000-8000-00805f9b34fb from a short 16 bit UUID by replacing +// the yyyy fields. If the given ID is still an arbitrary long one but without dashes, the dashes will be added. +// Additionally some simple checks for the resulting UUID will be done. +func convertUUID(cUUID string) (string, error) { + var uuid string switch len(cUUID) { case 4: - // convert to full uuid from "22bb" - uid, e := strconv.ParseUint("0x"+cUUID, 0, 16) - if e != nil { - return "" + uid, err := strconv.ParseUint(cUUID, 16, 16) + if err != nil { + return "", fmt.Errorf("'%s' is not a valid 16-bit Bluetooth UUID: %v", cUUID, err) } - - uuid := bluetooth.New16BitUUID(uint16(uid)) - return uuid.String() - + return bluetooth.New16BitUUID(uint16(uid)).String(), nil case 32: - // convert "22bb746f2bbd75542d6f726568705327" - // to "22bb746f-2bbd-7554-2d6f-726568705327" - return fmt.Sprintf("%s-%s-%s-%s-%s", cUUID[:8], cUUID[8:12], cUUID[12:16], cUUID[16:20], - cUUID[20:32]) + // convert "22bb746f2bbd75542d6f726568705327" to "22bb746f-2bbd-7554-2d6f-726568705327" + uuid = fmt.Sprintf("%s-%s-%s-%s-%s", cUUID[:8], cUUID[8:12], cUUID[12:16], cUUID[16:20], cUUID[20:]) + case 36: + uuid = cUUID } - return cUUID + if uuid != "" { + id := strings.ReplaceAll(uuid, "-", "") + _, errHigh := strconv.ParseUint(id[:16], 16, 64) + _, errLow := strconv.ParseUint(id[16:], 16, 64) + if errHigh == nil && errLow == nil { + return uuid, nil + } + } + + return "", fmt.Errorf("'%s' is not a valid 128-bit Bluetooth UUID", cUUID) } diff --git a/platforms/bleclient/uuid_test.go b/platforms/bleclient/uuid_test.go new file mode 100644 index 00000000..033b9276 --- /dev/null +++ b/platforms/bleclient/uuid_test.go @@ -0,0 +1,58 @@ +package bleclient + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_convertUUID(t *testing.T) { + tests := map[string]struct { + input string + want string + wantErr string + }{ + "32_bit": { + input: "12345678-4321-1234-4321-123456789abc", + want: "12345678-4321-1234-4321-123456789abc", + }, + "16_bit": { + input: "12f4", + want: "000012f4-0000-1000-8000-00805f9b34fb", + }, + "32_bit_without_dashes": { + input: "0123456789abcdef012345678abcdefc", + want: "01234567-89ab-cdef-0123-45678abcdefc", + }, + "error_bad_chacters_16bit": { + input: "123g", + wantErr: "'123g' is not a valid 16-bit Bluetooth UUID", + }, + "error_bad_chacters_32bit": { + input: "12345678-4321-1234-4321-123456789abg", + wantErr: "'12345678-4321-1234-4321-123456789abg' is not a valid 128-bit Bluetooth UUID", + }, + "error_too_long": { + input: "12345678-4321-1234-4321-123456789abcd", + wantErr: "'12345678-4321-1234-4321-123456789abcd' is not a valid 128-bit Bluetooth UUID", + }, + "error_invalid": { + input: "12345", + wantErr: "'12345' is not a valid 128-bit Bluetooth UUID", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // act + got, err := convertUUID(tc.input) + // assert + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErr) + } + assert.Equal(t, tc.want, got) + }) + } +}