From dadb65cf14cf9835823c1b7ba3d24a673545a642 Mon Sep 17 00:00:00 2001 From: Alexis Couvreur Date: Sat, 4 Oct 2025 12:28:35 -0400 Subject: [PATCH 1/4] feat(gap): add ConnectWithContext in gap_darwin.go Related https://github.com/tinygo-org/bluetooth/issues/339 --- gap_darwin.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/gap_darwin.go b/gap_darwin.go index 542535c4..dae4f712 100644 --- a/gap_darwin.go +++ b/gap_darwin.go @@ -1,6 +1,7 @@ package bluetooth import ( + "context" "errors" "fmt" "time" @@ -104,6 +105,11 @@ type deviceInternal struct { // Connect starts a connection attempt to the given peripheral device address. func (a *Adapter) Connect(address Address, params ConnectionParams) (Device, error) { + return a.ConnectWithContext(context.Background(), address, params) +} + +// ConnectWithContext starts a connection attempt to the given peripheral device address. +func (a *Adapter) ConnectWithContext(ctx context.Context, address Address, params ConnectionParams) (Device, error) { uuid, err := cbgo.ParseUUID(address.UUID.String()) if err != nil { return Device{}, err @@ -162,6 +168,16 @@ func (a *Adapter) Connect(address Address, params ConnectionParams) (Device, err // record an error to use when the disconnect comes through later. connectionError = errors.New("timeout on Connect") + // we are not ready to return yet, we need to wait for the disconnect event to come through + // so continue on from this case and wait for something to show up on prphCh + continue + case <-ctx.Done(): + // we need to cancel the connection if the context is done + a.cm.CancelConnect(prphs[0]) + + // record an error to use when the disconnect comes through later. + connectionError = ctx.Err() + // we are not ready to return yet, we need to wait for the disconnect event to come through // so continue on from this case and wait for something to show up on prphCh continue From 434206ba68f6bf0f3793fef29107d2c5d3504078 Mon Sep 17 00:00:00 2001 From: Alexis Couvreur Date: Sat, 4 Oct 2025 12:48:41 -0400 Subject: [PATCH 2/4] feat(gap): add DiscoverServicesWithContext DiscoverCharacteristicsWithContext WriteWithContext and ReadWithContext Related https://github.com/tinygo-org/bluetooth/issues/339 --- gap_darwin.go | 11 +++++++++ gattc_darwin.go | 60 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/gap_darwin.go b/gap_darwin.go index dae4f712..fc1c1434 100644 --- a/gap_darwin.go +++ b/gap_darwin.go @@ -39,6 +39,12 @@ func (ad *Address) Set(val string) { // Scan starts a BLE scan. It is stopped by a call to StopScan. A common pattern // is to cancel the scan when a particular device has been found. func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) (err error) { + return a.ScanWithContext(context.Background(), callback) +} + +// ScanWithContext starts a BLE scan. It is stopped by a call to StopScan. A common pattern +// is to cancel the scan when a particular device has been found. +func (a *Adapter) ScanWithContext(ctx context.Context, callback func(*Adapter, ScanResult)) (err error) { if callback == nil { return errors.New("must provide callback to Scan function") } @@ -63,6 +69,11 @@ func (a *Adapter) Scan(callback func(*Adapter, ScanResult)) (err error) { // the callback calls StopScan() (no new callbacks may be called after // StopScan is called). select { + case <-ctx.Done(): + // StopScan can return an error, but we ignore it here since + // it only returns an error if no scan is in progress. + _ = a.StopScan() + return ctx.Err() case <-a.scanChan: close(a.scanChan) a.scanChan = nil diff --git a/gattc_darwin.go b/gattc_darwin.go index 2d737dad..ca9ff571 100644 --- a/gattc_darwin.go +++ b/gattc_darwin.go @@ -1,6 +1,7 @@ package bluetooth import ( + "context" "errors" "time" @@ -15,6 +16,19 @@ import ( // Passing a nil slice of UUIDs will return a complete list of // services. func (d Device) DiscoverServices(uuids []UUID) ([]DeviceService, error) { + ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Second, errors.New("timeout on DiscoverServices")) + defer cancel() + return d.DiscoverServicesWithContext(ctx, uuids) +} + +// DiscoverServicesWithContext starts a service discovery procedure. Pass a list of service +// UUIDs you are interested in to this function. Either a slice of all services +// is returned (of the same length as the requested UUIDs and in the same +// order), or if some services could not be discovered an error is returned. +// +// Passing a nil slice of UUIDs will return a complete list of +// services. +func (d Device) DiscoverServicesWithContext(ctx context.Context, uuids []UUID) ([]DeviceService, error) { d.prph.DiscoverServices([]cbgo.UUID{}) // clear cache of services @@ -52,8 +66,8 @@ func (d Device) DiscoverServices(uuids []UUID) ([]DeviceService, error) { d.services[svc.uuidWrapper] = svc } return svcs, nil - case <-time.NewTimer(10 * time.Second).C: - return nil, errors.New("timeout on DiscoverServices") + case <-ctx.Done(): + return nil, ctx.Err() } } @@ -90,6 +104,21 @@ func (s DeviceService) UUID() UUID { // Passing a nil slice of UUIDs will return a complete list of // characteristics. func (s DeviceService) DiscoverCharacteristics(uuids []UUID) ([]DeviceCharacteristic, error) { + ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Second, errors.New("timeout on DiscoverCharacteristics")) + defer cancel() + return s.DiscoverCharacteristicsWithContext(ctx, uuids) +} + +// DiscoverCharacteristicsWithContext discovers characteristics in this service. Pass a +// list of characteristic UUIDs you are interested in to this function. Either a +// list of all requested services is returned, or if some services could not be +// discovered an error is returned. If there is no error, the characteristics +// slice has the same length as the UUID slice with characteristics in the same +// order in the slice as in the requested UUID list. +// +// Passing a nil slice of UUIDs will return a complete list of +// characteristics. +func (s DeviceService) DiscoverCharacteristicsWithContext(ctx context.Context, uuids []UUID) ([]DeviceCharacteristic, error) { cbuuids := []cbgo.UUID{} s.device.prph.DiscoverCharacteristics(cbuuids, s.service) @@ -136,8 +165,8 @@ func (s DeviceService) DiscoverCharacteristics(uuids []UUID) ([]DeviceCharacteri } } return chars, nil - case <-time.NewTimer(10 * time.Second).C: - return nil, errors.New("timeout on DiscoverCharacteristics") + case <-ctx.Done(): + return nil, ctx.Err() } } @@ -179,13 +208,21 @@ func (c DeviceCharacteristic) UUID() UUID { // Write replaces the characteristic value with a new value. The // call will return after all data has been written. func (c DeviceCharacteristic) Write(p []byte) (n int, err error) { + ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Second, errors.New("timeout on Write()")) + defer cancel() + return c.WriteWithContext(ctx, p) +} + +// WriteWithContext replaces the characteristic value with a new value. The +// call will return after all data has been written. +func (c DeviceCharacteristic) WriteWithContext(ctx context.Context, p []byte) (n int, err error) { c.writeChan = make(chan error) c.service.device.prph.WriteCharacteristic(p, c.characteristic, true) // wait for result select { - case <-time.NewTimer(10 * time.Second).C: - err = errors.New("timeout on Write()") + case <-ctx.Done(): + err = ctx.Err() case err = <-c.writeChan: } @@ -229,6 +266,13 @@ func (c DeviceCharacteristic) GetMTU() (uint16, error) { // Read reads the current characteristic value. func (c *deviceCharacteristic) Read(data []byte) (n int, err error) { + ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Second, errors.New("timeout on Read()")) + defer cancel() + return c.ReadWithContext(ctx, data) +} + +// ReadWithContext reads the current characteristic value. +func (c *deviceCharacteristic) ReadWithContext(ctx context.Context, data []byte) (n int, err error) { c.readChan = make(chan error) c.service.device.prph.ReadCharacteristic(c.characteristic) @@ -239,9 +283,9 @@ func (c *deviceCharacteristic) Read(data []byte) (n int, err error) { if err != nil { return 0, err } - case <-time.NewTimer(10 * time.Second).C: + case <-ctx.Done(): c.readChan = nil - return 0, errors.New("timeout on Read()") + return 0, ctx.Err() } copy(data, c.characteristic.Value()) From 828c879ce74e8df033f735525c708eca58423cd6 Mon Sep 17 00:00:00 2001 From: Alexis Couvreur Date: Sun, 12 Oct 2025 21:43:12 -0400 Subject: [PATCH 3/4] wait for scanChan in ctx Done for scanning --- gap_darwin.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/gap_darwin.go b/gap_darwin.go index fc1c1434..ae5165b5 100644 --- a/gap_darwin.go +++ b/gap_darwin.go @@ -70,9 +70,14 @@ func (a *Adapter) ScanWithContext(ctx context.Context, callback func(*Adapter, S // StopScan is called). select { case <-ctx.Done(): - // StopScan can return an error, but we ignore it here since - // it only returns an error if no scan is in progress. - _ = a.StopScan() + err = a.StopScan() + if err != nil { + return errors.Join(err, ctx.Err()) + } + + <-a.scanChan + close(a.scanChan) + a.scanChan = nil return ctx.Err() case <-a.scanChan: close(a.scanChan) From 850f0fb40646c9a7311d7bf35cef1b9810bcdbc6 Mon Sep 17 00:00:00 2001 From: Alexis Couvreur Date: Tue, 7 Apr 2026 09:55:28 +0200 Subject: [PATCH 4/4] add WithContext in the common interfaces --- adapter.go | 3 +++ gap.go | 2 ++ gattc.go | 14 ++++++++++++++ gattc_darwin.go | 7 +++++++ 4 files changed, 26 insertions(+) diff --git a/adapter.go b/adapter.go index 03d25dac..b2dfa785 100644 --- a/adapter.go +++ b/adapter.go @@ -1,8 +1,11 @@ package bluetooth +import "context" + // BLEAdapter is the shared interface that all platform-specific Adapter types must implement. type BLEAdapter interface { Connect(address Address, params ConnectionParams) (Device, error) + ConnectWithContext(ctx context.Context, address Address, params ConnectionParams) (Device, error) Enable() error Scan(callback func(*Adapter, ScanResult)) (err error) SetConnectHandler(c func(device Device, connected bool)) diff --git a/gap.go b/gap.go index 8190fb69..6cb4c9bd 100644 --- a/gap.go +++ b/gap.go @@ -1,6 +1,7 @@ package bluetooth import ( + "context" "errors" "time" ) @@ -134,6 +135,7 @@ type Connection uint16 // GAPDevice is the shared interface that all platform-specific Device types must implement. type GAPDevice interface { DiscoverServices(uuids []UUID) ([]DeviceService, error) + DiscoverServicesWithContext(ctx context.Context, uuids []UUID) ([]DeviceService, error) RequestConnectionParams(params ConnectionParams) error Connected() (bool, error) Disconnect() error diff --git a/gattc.go b/gattc.go index 3435eb47..2822d3c7 100644 --- a/gattc.go +++ b/gattc.go @@ -2,6 +2,8 @@ package bluetooth +import "context" + // GATTCService is the common interface that all platform-specific // DeviceService types must implement. type GATTCService interface { @@ -13,6 +15,9 @@ type GATTCService interface { // function. Either a list of all requested services is returned, or if // some services could not be discovered an error is returned. DiscoverCharacteristics(uuids []UUID) ([]DeviceCharacteristic, error) + + // DiscoverCharacteristicsWithContext is the same as DiscoverCharacteristics but allows passing a context for cancellation and timeouts. + DiscoverCharacteristicsWithContext(ctx context.Context, uuids []UUID) ([]DeviceCharacteristic, error) } // GATTCCharacteristic is the common interface that all platform-specific @@ -24,13 +29,22 @@ type GATTCCharacteristic interface { // Read reads the current characteristic value. Read(data []byte) (int, error) + // ReadWithContext is the same as Read but allows passing a context for cancellation and timeouts. + ReadWithContext(ctx context.Context, data []byte) (int, error) + // Write replaces the characteristic value with a new value. Write(p []byte) (n int, err error) + // WriteWithContext is the same as Write but allows passing a context for cancellation and timeouts. + WriteWithContext(ctx context.Context, p []byte) (n int, err error) + // WriteWithoutResponse replaces the characteristic value with a new // value. The call will return before all data has been written. WriteWithoutResponse(p []byte) (n int, err error) + // WriteWithoutResponseWithContext is the same as WriteWithoutResponse but allows passing a context for cancellation and timeouts. + WriteWithoutResponseWithContext(ctx context.Context, p []byte) (n int, err error) + // EnableNotifications enables notifications for this characteristic, // calling the provided callback with the new value when the // characteristic value is changed by the remote peripheral. diff --git a/gattc_darwin.go b/gattc_darwin.go index f230ff7d..71ded21d 100644 --- a/gattc_darwin.go +++ b/gattc_darwin.go @@ -287,6 +287,13 @@ func (c DeviceCharacteristic) WriteWithoutResponse(p []byte) (int, error) { return len(p), nil } +// WriteWithoutResponseWithContext replaces the characteristic value with a new value. +// On Darwin, the context parameter is accepted for interface compatibility but +// not used for cancellation since WriteWithoutResponse is non-blocking. +func (c DeviceCharacteristic) WriteWithoutResponseWithContext(ctx context.Context, p []byte) (int, error) { + return c.WriteWithoutResponse(p) +} + // EnableNotifications enables notifications in the Client Characteristic // Configuration Descriptor (CCCD). This means that most peripherals will send a // notification with a new value every time the value of the characteristic