ble(client): add scan timout (#1051)

This commit is contained in:
Thomas Kohler 2024-02-11 17:01:24 +01:00 committed by GitHub
parent d2b01b99e0
commit 9430005b82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 967 additions and 140 deletions

View File

@ -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() {

View File

@ -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() {

View File

@ -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() {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
})
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
})
}
}