ble is a Go Bluetooth Low Energy package for Linux (raw HCI sockets) and macOS (CoreBluetooth via cbgo).
This is a maintained fork of the dormant go-ble/ble
(merge base 8c5522f). The module is renamed github.com/bdstark/ble and the
API diverges deliberately — it will not be upstreamed. It exists to run an
unattended BLE telemetry gateway, so the changes are biased toward one goal:
the stack must never wedge, and every failure must surface as a typed,
bounded error.
Linux is the production target. The macOS backend is for development; several of the guarantees below (bounded waits, connection-parameter control) apply only to the Linux stack.
- Examples: bdstark/ble-examples
- Test coverage of the fork's changes: COVERAGE.md
(diff coverage of added lines: 91.7% overall, 99.6% excluding
darwin/) - CI: GitHub Actions runs build/vet/test (plus
-raceand a repeated-run pass over the HCI/ATT/GATT packages) on Linux and macOS
Requires Go 1.26+. On Linux the process needs CAP_NET_ADMIN (or root) to
open the HCI socket, and the interface must be free of a competing host stack
(stop or mask bluetoothd, or run on a dedicated controller).
go get github.com/bdstark/blecontext.Contextthreads through the entireble.Clientrequest API. Every GATT operation — discovery, reads, writes, subscribe/unsubscribe,ExchangeMTU,ReadRSSI— takes actxthat bounds the caller's wait. On expiry you getctx.Err()(possibly wrapped;errors.Isagainstcontext.Canceled/context.DeadlineExceededholds). Cancellation is best-effort at the transport layer: an in-flight ACL write finishes, but it is independently bounded byhci.ACLWriteTimeout. A request abandoned after it reached the wire still owns the ATT bearer — the next request first waits out that transaction (late response, or its 30 s spec deadline, which closes the bearer), so a canceled request can never cause a later one to consume a stale response. Teardown (CancelConnection) deliberately takes no context so a client can always be torn down.- Typed sentinel errors, stdlib wrapping.
pkg/errorsis gone. Failures wrap sentinels you can test witherrors.Is:ble.ErrNotImplemented,ble.ErrInvalidConnParams,ble.ErrInvalidDataLength, and on Linuxhci.ErrClosed(wrapsio.ErrClosedPipe),hci.ErrCreditTimeout,hci.ErrCommandTimeout,hci.ErrConnUpdateTimeout,att.ErrSeqProtoTimeout, plus the spec-definedble.ATTErrorandhci.ErrCommandcode types. Conn.UpdateParams(ctx, ble.ConnParams{...})— LE Connection Update on a live central link, expressed intime.Durationunits, validated against the spec ranges before touching the controller, blocking until the controller reports completion. Peer-requested (L2CAP) updates and local requests share the per-connection update slot, so they cannot misattribute each other's completion events.Conn.SetDataLength(ctx, txOctets, txTime)— LE Data Length Extension request, validated byble.ValidateDataLength; the negotiated values are readable via the Linux conn'sDataLength()getter as LE Data Length Change events arrive.- API repairs:
ReadRSSIperforms a real HCI Read RSSI exchange (it returned a fabricated0upstream),DiscoverIncludedServicesis implemented (upstream returnednil, nil), andClient.Name()reads the GAP Device Name characteristic, caching deterministic outcomes rather than re-querying on every call. ble.Connectcancellation race fixed. Upstream decided "found" by which error the scan returned; a parent-context expiry could race the match, andConnectwould block forever on a channel nobody would send to. Found-ness is now decided solely by draining a buffered found channel. This wedge was hit in production.- Options propagate setter errors instead of silently discarding them,
and device init failures fail
NewDevice/Initinstead of returning a booby-trapped half-initialized HCI. - Notification handler contract: the
[]bytepassed to aNotificationHandleris backed by a pooled buffer on Linux and is valid only for the duration of the call — copy it if you keep it.
- Every indefinite channel wait in the stack is bounded. Command
responses, ACL buffer credits, connection updates, disconnects, connection
cancels — all have timeouts, hoisted into package variables (the exported
hci.ACLWriteTimeoutis the public knob; the rest exist so tests can shrink them), so a dead controller or peer produces a typed error instead of a parked goroutine. - Controller-state reconciliation after abandoned commands. When a context expiry abandons an HCI command that the controller later executes anyway (the classic "Command Disallowed on every subsequent scan" wedge), the stack reconciles its scan/dial state instead of desyncing permanently.
sktLoopwedge classes closed. Socket death tears down all connections; reads/writes on a dying transport returnhci.ErrClosedrather than stalling;Socket.Closeis idempotent and no longer depends on the controller being alive to complete.- Connection lifecycle fixes: a lost Disconnect command no longer leaves a zombie connection; a failed connect no longer leaks its conn; one connection's wait for ACL credits no longer stalls writes on every other connection; mid-reassembly disconnects no longer panic.
- ATT bearer discipline per spec: an ATT transaction timeout or an unconfirmed indication poisons the bearer and closes it (Vol 3, Part F), rather than leaving a half-dead exchange to corrupt the next request.
- Client RX paths hardened against runt, malformed, stale, and hostile PDUs — a misbehaving peer gets an error or a disconnect, not a panic.
- ATT server: nine inherited bugs fixed, among them three remotely
triggerable panics, an
ExecuteWritepanic, andPrepareWritedropping the queued value.ExchangeMTUhandling conforms to Vol 3, Part F 3.4.2.2. - GATT server subscription state records only peer-acknowledged CCCD writes, so notification state cannot outrun the peer.
- Wire-format fixes:
CommandReject.Marshalproduced unsendable frames; SMP frames parsed the opcode from the wrong offset with an incorrect data length. Both fixed.
- Advertising delivery runs through a bounded dispatcher with pooled buffers instead of upstream's goroutine-per-advertisement; the parsed advertisement packet is cached across field accesses.
- Receive-path buffers are pooled where lifetimes provably allow it; ACL packet headers are built without reflection. Micro-benchmarks cover the hot paths.
- Logging is stdlib
log/slogviable.SetLogger/ble.Logger()(logxi is gone), with atomic access so the logger can be swapped safely while a device runs. Hot-path debug sites checkEnabledfirst, so debug-off costs nothing. examples/moved to bdstark/ble-examples; dead pre-cbgo darwin XPC code deleted; modern idiomatic Go throughout (any,min,slices, …).- The darwin backend's
ctx.Doneabandon paths can no longer deadlock the CoreBluetooth dispatch thread. - Diff coverage of every fork change is tracked in COVERAGE.md and measured with tools/diffcover.py.
import (
"github.com/bdstark/ble"
"github.com/bdstark/ble/linux"
)
d, err := linux.NewDevice() // opens and initializes the default HCI device
if err != nil {
log.Fatalf("can't open BLE device: %v", err)
}
ble.SetDefaultDevice(d)Options are validated and their errors surface:
d, err := linux.NewDevice(
ble.OptDeviceID(1), // hci1 instead of hci0
ble.OptCentralRole(),
)ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := ble.Scan(ctx, false, func(a ble.Advertisement) {
fmt.Printf("%s %s rssi=%d\n", a.Addr(), a.LocalName(), a.RSSI())
}, nil)
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
log.Printf("scan failed: %v", err)
}The handler runs on the bounded advertising dispatcher — keep it fast, and copy anything you retain from the advertisement.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Connect scans until an advertisement matches, then dials it.
cln, err := ble.Connect(ctx, func(a ble.Advertisement) bool {
return a.LocalName() == "my-sensor"
})
if err != nil {
log.Fatalf("connect: %v", err) // errors.Is(err, context.DeadlineExceeded) on timeout
}
defer cln.CancelConnection()
p, err := cln.DiscoverProfile(ctx, true)
if err != nil {
log.Fatalf("discover: %v", err)
}If you already know the address, dial directly: ble.Dial(ctx, ble.NewAddr("aa:bb:cc:dd:ee:ff")).
char := p.FindCharacteristic(ble.NewCharacteristic(ble.MustParse("2a19"))) // Battery Level
if char == nil {
log.Fatal("characteristic not found")
}
opCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
val, err := cln.ReadCharacteristic(opCtx, char)
if err != nil {
log.Fatalf("read: %v", err)
}
err = cln.WriteCharacteristic(opCtx, char, []byte{0x01}, false /* with response */)err := cln.Subscribe(ctx, char, false /* notify, true = indicate */, func(data []byte) {
// data is only valid inside this call — it is backed by a pooled
// buffer. Copy before sending it anywhere.
buf := make([]byte, len(data))
copy(buf, data)
readings <- buf
})
if err != nil {
log.Fatalf("subscribe: %v", err)
}
// Block until the peer drops or you decide to leave.
select {
case <-cln.Disconnected():
log.Print("peer disconnected")
case <-ctx.Done():
_ = cln.CancelConnection()
}// Relax the connection interval on a live link, e.g. to share radio time
// across several concurrent connections.
err := cln.Conn().UpdateParams(ctx, ble.ConnParams{
IntervalMin: 30 * time.Millisecond,
IntervalMax: 50 * time.Millisecond,
Latency: 0,
Timeout: 4 * time.Second,
})
if errors.Is(err, ble.ErrInvalidConnParams) {
// rejected locally before touching the controller
}
// Ask for the controller's maximum LE packet length (fewer, larger packets).
err = cln.Conn().SetDataLength(ctx, ble.DataLengthMaxTxOctets, ble.DataLengthMaxTxTime)UpdateParams blocks until the controller reports the update complete.
SetDataLength returns once the controller accepts the command; the
negotiated values arrive asynchronously (readable on the Linux conn via
DataLength()). On backends that manage these themselves (CoreBluetooth),
both return a wrapped ble.ErrNotImplemented.
Wrapped sentinels make failure modes distinguishable:
_, err := cln.ReadCharacteristic(ctx, char)
switch {
case errors.Is(err, context.DeadlineExceeded):
// this call's ctx expired; the link may still be fine
case errors.Is(err, att.ErrSeqProtoTimeout):
// ATT transaction timed out; the bearer is poisoned, reconnect
case errors.Is(err, hci.ErrClosed): // also matches io.ErrClosedPipe
// transport is gone
}
var attErr ble.ATTError
if errors.As(err, &attErr) && attErr == ble.ErrReadNotPerm {
// peer refused the read at the protocol level
}The bounded waits use package variables; adjust them before opening a device if your controller or peers need different ceilings:
hci.ACLWriteTimeout = 30 * time.Second // wait for ACL buffer creditsble.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug, // debug enables packet-level dumps
})))ble.SetLogger is safe to call at any time — access is atomic, so
reconfiguring logging on a running device doesn't race the stack's log
sites. ble.Logger() returns the current logger (or slog.Default() if
none was set).
d, _ := linux.NewDevice(ble.OptPeripheralRole())
ble.SetDefaultDevice(d)
svc := ble.NewService(ble.MustParse("19fad5e6-0000-4a86-9e44-95b9cee0f730"))
svc.NewCharacteristic(ble.MustParse("19fad5e6-0001-4a86-9e44-95b9cee0f730")).
HandleRead(ble.ReadHandlerFunc(func(req ble.Request, rsp ble.ResponseWriter) {
rsp.Write([]byte("hello"))
}))
if err := ble.AddService(svc); err != nil {
log.Fatal(err)
}
ctx := ble.WithSigHandler(context.WithCancel(context.Background()))
log.Fatal(ble.AdvertiseNameAndServices(ctx, "my-device", svc.UUID))The linux backend is pure Go above the socket layer, so linux/hci and
linux/att build and test on any platform:
go build ./...
go vet ./...
go test ./...Diff coverage of the fork's changes (see COVERAGE.md):
go test -count=1 -coverprofile=/tmp/cover.out -coverpkg=./...,github.com/bdstark/ble/... ./...
python3 tools/diffcover.py . 8c5522f..HEAD /tmp/cover.outBSD-3-Clause, inherited from upstream — see LICENSE.