drivers/sesame/bluez.go
raw ยท 19756 bytes
// SPDX-License-Identifier: Apache-2.0
package sesame
import (
"context"
"errors"
"fmt"
"log"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/godbus/dbus/v5"
)
// A capture from a Sesame 6 Pro showed valid discovery responses about one
// second apart on a 500 ms connection interval. Eight seconds aborts discovery
// before BlueZ can finish enumerating characteristics and descriptors.
const (
setupTimeout = 60 * time.Second
gattTimeout = 45 * time.Second
)
type Link interface {
Write(context.Context, []byte) error
Packets() <-chan []byte
Done() <-chan error
Close()
}
type Dialer func(context.Context, Target) (Link, Target, error)
type Adapter struct {
ID string `json:"id"`
Name string `json:"name"`
Powered bool `json:"powered"`
}
type Discovery struct {
Adapters []Adapter `json:"adapters"`
Locks []Target `json:"locks"`
Warnings []string `json:"warnings"`
}
type objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
func getObjects(ctx context.Context, c *dbus.Conn) (objects, error) {
var o objects
e := c.Object("org.bluez", "/").CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&o)
return o, e
}
func value(p map[string]dbus.Variant, k string) string { s, _ := p[k].Value().(string); return s }
var adapterPattern = regexp.MustCompile(`^hci[0-9]+$`)
func ValidateTarget(t Target) error {
if _, e := NormalizeUUID(t.UUID); e != nil {
return e
}
if !Supported(t.Model) {
return errors.New("unsupported Sesame model")
}
if !adapterPattern.MatchString(t.Adapter) {
return errors.New("invalid Bluetooth adapter")
}
return nil
}
func advertised(p map[string]dbus.Variant) (Target, bool) {
// BlueZ strips the two-byte company identifier. Do not accept arbitrary
// manufacturer payloads merely because their first byte matches a model.
md, ok := p["ManufacturerData"].Value().(map[uint16]dbus.Variant)
if !ok {
return Target{}, false
}
v, ok := md[0x055a]
if !ok {
return Target{}, false
}
b, ok := v.Value().([]byte)
if !ok {
return Target{}, false
}
t, ok := Advertisement(b)
if !ok {
return Target{}, false
}
a, _ := p["Adapter"].Value().(dbus.ObjectPath)
parts := strings.Split(string(a), "/")
t.Adapter = parts[len(parts)-1]
t.Address = value(p, "Address")
return t, true
}
func snapshot(o objects) Discovery {
d := Discovery{Adapters: []Adapter{}, Locks: []Target{}, Warnings: []string{}}
for path, ifs := range o {
if p, ok := ifs["org.bluez.Adapter1"]; ok {
v, _ := p["Powered"].Value().(bool)
parts := strings.Split(string(path), "/")
d.Adapters = append(d.Adapters, Adapter{parts[len(parts)-1], value(p, "Alias"), v})
}
if p, ok := ifs["org.bluez.Device1"]; ok {
if t, ok := advertised(p); ok {
d.Locks = append(d.Locks, t)
}
}
}
sort.Slice(d.Adapters, func(i, j int) bool { return d.Adapters[i].ID < d.Adapters[j].ID })
sort.Slice(d.Locks, func(i, j int) bool { return d.Locks[i].Adapter+d.Locks[i].UUID < d.Locks[j].Adapter+d.Locks[j].UUID })
return d
}
func pause(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func stopScan(a dbus.BusObject) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = a.CallWithContext(ctx, "org.bluez.Adapter1.StopDiscovery", 0).Err
}
// Request current LE reports, including unchanged manufacturer data. Without a
// filter BlueZ suppresses duplicates and can leave us choosing an old random MAC.
func startScan(ctx context.Context, a dbus.BusObject) error {
filter := map[string]dbus.Variant{
"Transport": dbus.MakeVariant("le"),
"UUIDs": dbus.MakeVariant([]string{ServiceUUID}),
"DuplicateData": dbus.MakeVariant(true),
}
if e := a.CallWithContext(ctx, "org.bluez.Adapter1.SetDiscoveryFilter", 0, filter).Err; e != nil {
return e
}
return a.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err
}
func Discover(ctx context.Context) (Discovery, error) {
return DiscoverWithCallback(ctx, nil)
}
// DiscoverWithCallback reports candidates during the scan, allowing the UI's
// registration links to start connecting before the scan finishes. DialBlueZ
// still requires a fresh advertisement before opening any candidate connection.
func DiscoverWithCallback(ctx context.Context, found func([]Target)) (Discovery, error) {
c, e := dbus.ConnectSystemBus()
if e != nil {
return Discovery{}, fmt.Errorf("Bluetooth unavailable: %w", e)
}
defer c.Close()
o, e := getObjects(ctx, c)
if e != nil {
return Discovery{}, e
}
d := snapshot(o)
var active []dbus.BusObject
defer func() {
for _, a := range active {
stopScan(a)
}
}()
for _, a := range d.Adapters {
if !a.Powered {
d.Warnings = append(d.Warnings, a.ID+" is switched off")
continue
}
obj := c.Object("org.bluez", dbus.ObjectPath("/org/bluez/"+a.ID))
if e := startScan(ctx, obj); e != nil {
d.Warnings = append(d.Warnings, a.ID+": "+e.Error())
} else {
active = append(active, obj)
}
}
if len(active) > 0 {
if found == nil {
if e := pause(ctx, 5*time.Second); e != nil {
return Discovery{}, e
}
} else {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
seen := map[string]bool{}
scan:
for {
select {
case <-ctx.Done():
return Discovery{}, ctx.Err()
case <-deadline.C:
break scan
case <-ticker.C:
current, err := getObjects(ctx, c)
if err != nil {
return Discovery{}, err
}
var candidates []Target
for _, t := range snapshot(current).Locks {
id := t.UUID // One preparation per physical lock during this scan.
if !seen[id] {
seen[id] = true
candidates = append(candidates, t)
}
}
if len(candidates) > 0 {
found(candidates)
}
}
}
}
}
o, e = getObjects(ctx, c)
if e != nil {
return Discovery{}, e
}
out := snapshot(o)
out.Warnings = d.Warnings
if len(out.Adapters) == 0 {
out.Warnings = append(out.Warnings, "No local Bluetooth adapters found")
}
return out, nil
}
// Choose the most recently advertised identity, rather than an arbitrary
// cached address. BlueZ can retain several Device1 entries for one Sesame UUID.
func chooseTarget(o objects, t Target, seen map[dbus.ObjectPath]uint64) (dbus.ObjectPath, Target) {
var selected dbus.ObjectPath
var found Target
for path, ifs := range o {
candidate, ok := advertised(ifs["org.bluez.Device1"])
if !ok || !strings.EqualFold(candidate.UUID, t.UUID) || candidate.Adapter != t.Adapter || seen[path] == 0 {
continue
}
if selected == "" || seen[path] > seen[selected] || (seen[path] == seen[selected] && string(path) < string(selected)) {
selected, found = path, candidate
}
}
return selected, found
}
// disconnectDevice waits for BlueZ's link state, not just acceptance of the
// Disconnect method. A timed-out setup can otherwise leave Connected=true and
// make every later preparation reject the device before it can subscribe.
func disconnectDevice(ctx context.Context, c *dbus.Conn, path dbus.ObjectPath) error {
cleanup, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
callError := c.Object("org.bluez", path).CallWithContext(cleanup, "org.bluez.Device1.Disconnect", 0).Err
// A concurrent disconnect can make the method fail even though the desired
// state has been reached. Treat the observed link state as authoritative.
for {
o, e := getObjects(cleanup, c)
if e != nil {
return fmt.Errorf("confirm Sesame disconnect: %w", errors.Join(callError, e))
}
connected, _ := o[path]["org.bluez.Device1"]["Connected"].Value().(bool)
if !connected {
return nil
}
if e := pause(cleanup, 50*time.Millisecond); e != nil {
return fmt.Errorf("wait for Sesame link to disconnect: %w", errors.Join(callError, e))
}
}
}
// StartDiscovery is reference counted. These signal matches and the scan belong
// only to this D-Bus client; do not remove BlueZ devices or reset the adapter.
func findTarget(ctx context.Context, c *dbus.Conn, t Target) (dbus.ObjectPath, Target, error) {
adapterPath := dbus.ObjectPath("/org/bluez/" + t.Adapter)
signals := make(chan *dbus.Signal, 256)
c.Signal(signals)
defer c.RemoveSignal(signals)
properties := []dbus.MatchOption{dbus.WithMatchSender("org.bluez"), dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchPathNamespace(adapterPath)}
added := []dbus.MatchOption{dbus.WithMatchSender("org.bluez"), dbus.WithMatchInterface("org.freedesktop.DBus.ObjectManager"), dbus.WithMatchMember("InterfacesAdded")}
if e := c.AddMatchSignal(properties...); e != nil {
return "", Target{}, e
}
defer c.RemoveMatchSignal(properties...)
if e := c.AddMatchSignal(added...); e != nil {
return "", Target{}, e
}
defer c.RemoveMatchSignal(added...)
o, e := getObjects(ctx, c)
if e != nil {
return "", Target{}, e
}
for path, ifs := range o {
p := ifs["org.bluez.Device1"]
candidate, ok := advertised(p)
connected, _ := p["Connected"].Value().(bool)
if ok && connected && strings.EqualFold(candidate.UUID, t.UUID) && candidate.Adapter == t.Adapter {
if candidate.Model != t.Model {
return "", Target{}, errors.New("Sesame model changed; scan again")
}
// Live preparations/clients never redial their own sessions. Reaching
// this point means this dialer has no token/cipher for the existing
// BlueZ link; reconnect just this device to obtain a fresh initial.
log.Printf("Sesame %s at %s on %s: recovering an existing BLE link before session setup", t.UUID, candidate.Address, t.Adapter)
if e := disconnectDevice(ctx, c, path); e != nil {
return "", Target{}, &setupRetryError{e}
}
}
}
adapter := c.Object("org.bluez", adapterPath)
if e := startScan(ctx, adapter); e != nil {
return "", Target{}, fmt.Errorf("start Sesame scan on %s: %w", t.Adapter, e)
}
defer stopScan(adapter)
seen := map[dbus.ObjectPath]uint64{}
var sequence uint64
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return "", Target{}, fmt.Errorf("discover Sesame %s on %s: %w", t.UUID, t.Adapter, ctx.Err())
case sig, ok := <-signals:
if !ok {
return "", Target{}, errors.New("Bluetooth bus closed during discovery")
}
if sig == nil || len(sig.Body) < 2 {
continue
}
var path dbus.ObjectPath
switch sig.Name {
case "org.freedesktop.DBus.Properties.PropertiesChanged":
props, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok || sig.Body[0] != "org.bluez.Device1" {
continue
}
_, manufacturer := props["ManufacturerData"]
_, rssi := props["RSSI"]
if !manufacturer && !rssi {
continue
}
path = sig.Path
case "org.freedesktop.DBus.ObjectManager.InterfacesAdded":
path, _ = sig.Body[0].(dbus.ObjectPath)
ifs, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
if !ok {
continue
}
if _, ok := ifs["org.bluez.Device1"]; !ok {
continue
}
default:
continue
}
if strings.HasPrefix(string(path), string(adapterPath)+"/") {
sequence++
seen[path] = sequence
}
case <-ticker.C:
o, e := getObjects(ctx, c)
if e != nil {
return "", Target{}, fmt.Errorf("read Sesame scan: %w", e)
}
path, found := chooseTarget(o, t, seen)
// Like the SDK's ReceivedAdV state, require a report from this scan.
if path != "" {
if found.Model != t.Model {
return "", Target{}, errors.New("Sesame model changed; scan again")
}
connected, _ := o[path]["org.bluez.Device1"]["Connected"].Value().(bool)
if connected {
// A connection may complete while we scan (for example a
// late completion of an earlier canceled Connect call).
if e := disconnectDevice(ctx, c, path); e != nil {
return "", Target{}, &setupRetryError{e}
}
return "", Target{}, &setupRetryError{errors.New("recovered a BLE link established during discovery; rescan for a fresh session")}
}
return path, found, nil
}
}
}
}
type bluezLink struct {
conn *dbus.Conn
device, write dbus.BusObject
notify dbus.BusObject
mode string
packets chan []byte
done chan error
cancel context.CancelFunc
once sync.Once
}
func (b *bluezLink) Packets() <-chan []byte { return b.packets }
func (b *bluezLink) Done() <-chan error { return b.done }
func (b *bluezLink) Write(ctx context.Context, data []byte) error {
return b.write.CallWithContext(ctx, "org.bluez.GattCharacteristic1.WriteValue", 0, data, map[string]dbus.Variant{"type": dbus.MakeVariant(b.mode)}).Err
}
func (b *bluezLink) Close() {
b.once.Do(func() {
b.cancel()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// Release our notification subscription before disconnecting, as the SDK
// does. Use a separate budget so a failed StopNotify cannot skip cleanup.
_ = b.notify.CallWithContext(ctx, "org.bluez.GattCharacteristic1.StopNotify", 0).Err
cancel()
ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second)
_ = b.device.CallWithContext(ctx, "org.bluez.Device1.Disconnect", 0).Err
cancel()
b.conn.Close()
})
}
// Only transport setup is retried. Login, registration and motor commands must
// never be replayed: the device may already have acted on them.
type setupRetryError struct{ error }
func (e *setupRetryError) Unwrap() error { return e.error }
func dialSetup(ctx context.Context, t Target, dial Dialer) (Link, Target, error) {
var last error
for attempt := 1; attempt <= 3; attempt++ {
if e := ctx.Err(); e != nil {
return nil, Target{}, fmt.Errorf("Sesame connection setup: %w", errors.Join(last, e))
}
l, actual, e := dial(ctx, t)
if e == nil {
return l, actual, nil
}
last = e
var retry *setupRetryError
if !errors.As(e, &retry) || attempt == 3 || ctx.Err() != nil {
break
}
log.Printf("Sesame %s on %s: setup attempt %d failed: %v; rescanning", t.UUID, t.Adapter, attempt, e)
if e := pause(ctx, 300*time.Millisecond); e != nil {
return nil, Target{}, fmt.Errorf("Sesame connection setup: %w", errors.Join(last, e))
}
}
return nil, Target{}, last
}
func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
if e := ValidateTarget(t); e != nil {
return nil, Target{}, e
}
// Also bound callers which do not supply the normal Client/Probe deadline.
setup, cancel := context.WithTimeout(ctx, setupTimeout)
defer cancel()
return dialSetup(setup, t, dialBlueZOnce)
}
func dialBlueZOnce(ctx context.Context, t Target) (Link, Target, error) {
if e := ValidateTarget(t); e != nil {
return nil, Target{}, e
}
c, e := dbus.ConnectSystemBus()
if e != nil {
return nil, Target{}, e
}
return dialBlueZConn(ctx, t, c)
}
// The connection is owned by this attempt, including all failure paths.
func dialBlueZConn(ctx context.Context, t Target, c *dbus.Conn) (Link, Target, error) {
success := false
var device dbus.BusObject
owned := false
defer func() {
if !success {
if owned {
cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = device.CallWithContext(cleanup, "org.bluez.Device1.Disconnect", 0).Err
}
c.Close()
}
}()
path, found, e := findTarget(ctx, c, t)
if e != nil {
return nil, Target{}, e
}
device = c.Object("org.bluez", path)
owned = true
connecting, cancelConnect := context.WithTimeout(ctx, 8*time.Second)
e = device.CallWithContext(connecting, "org.bluez.Device1.Connect", 0).Err
cancelConnect()
if e != nil {
return nil, Target{}, &setupRetryError{fmt.Errorf("connect Sesame %s on %s: %w", found.Address, t.Adapter, e)}
}
log.Printf("Sesame %s at %s on %s: BLE connected; resolving GATT", t.UUID, found.Address, t.Adapter)
gatt, cancelGATT := context.WithTimeout(ctx, gattTimeout)
defer cancelGATT()
var writePath, notifyPath dbus.ObjectPath
mode := ""
for writePath == "" || notifyPath == "" {
o, e := getObjects(gatt, c)
if e != nil {
return nil, Target{}, &setupRetryError{fmt.Errorf("discover Sesame GATT services at %s: %w", found.Address, e)}
}
connected, _ := o[path]["org.bluez.Device1"]["Connected"].Value().(bool)
if !connected {
return nil, Target{}, &setupRetryError{errors.New("Sesame disconnected during GATT discovery")}
}
resolved, _ := o[path]["org.bluez.Device1"]["ServicesResolved"].Value().(bool)
if resolved {
for p, ifs := range o {
v, ok := ifs["org.bluez.GattCharacteristic1"]
if !ok {
continue
}
sp, _ := v["Service"].Value().(dbus.ObjectPath)
service := o[sp]["org.bluez.GattService1"]
owner, _ := service["Device"].Value().(dbus.ObjectPath)
if owner != path || !strings.EqualFold(value(service, "UUID"), ServiceUUID) {
continue
}
switch strings.ToLower(value(v, "UUID")) {
case WriteUUID:
flags, _ := v["Flags"].Value().([]string)
for _, f := range flags {
if f == "write" && mode == "" {
mode = "request"
}
if f == "write-without-response" {
mode = "command"
}
}
writePath = p
case NotifyUUID:
notifyPath = p
}
}
}
if resolved && (writePath == "" || notifyPath == "") {
return nil, Target{}, errors.New("Sesame GATT services resolved without command/notification characteristics")
}
if writePath == "" || notifyPath == "" {
if e = pause(gatt, 100*time.Millisecond); e != nil {
return nil, Target{}, &setupRetryError{fmt.Errorf("discover Sesame GATT services at %s: %w", found.Address, e)}
}
}
}
if mode == "" {
return nil, Target{}, errors.New("Sesame command characteristic is not writable")
}
signals := make(chan *dbus.Signal, 256)
c.Signal(signals)
if e = c.AddMatchSignal(dbus.WithMatchSender("org.bluez"), dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchPathNamespace(path)); e != nil {
return nil, Target{}, e
}
life, cancel := context.WithCancel(context.Background())
b := &bluezLink{conn: c, device: device, notify: c.Object("org.bluez", notifyPath), write: c.Object("org.bluez", writePath), mode: mode, packets: make(chan []byte, 128), done: make(chan error, 1), cancel: cancel}
go func() {
fail := func(e error) {
select {
case b.done <- e:
default:
}
}
defer c.RemoveSignal(signals)
for {
select {
case <-life.Done():
return
case sig, ok := <-signals:
if !ok {
fail(errors.New("Bluetooth bus disconnected"))
return
}
if sig == nil || len(sig.Body) < 2 {
continue
}
props, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
continue
}
if sig.Path == path {
if v, exists := props["Connected"]; exists {
on, _ := v.Value().(bool)
if !on {
fail(errors.New("Sesame disconnected"))
return
}
}
}
if sig.Path == notifyPath {
if data, ok := props["Value"].Value().([]byte); ok {
select {
case b.packets <- append([]byte(nil), data...):
default:
fail(errors.New("Sesame notification queue overflow"))
return
}
}
}
}
}
}()
if e = b.notify.CallWithContext(gatt, "org.bluez.GattCharacteristic1.StartNotify", 0).Err; e != nil {
cancel()
return nil, Target{}, fmt.Errorf("subscribe to Sesame notifications at %s: %w", found.Address, e)
}
log.Printf("Sesame %s at %s: notifications enabled; waiting for initial token", t.UUID, found.Address)
success = true
return b, found, nil
}