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