// SPDX-License-Identifier: Apache-2.0 package sesame import ( "context" "errors" "fmt" "regexp" "sort" "strings" "sync" "time" "github.com/godbus/dbus/v5" ) 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 } func Discover(ctx context.Context) (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 := obj.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; e != nil { d.Warnings = append(d.Warnings, a.ID+": "+e.Error()) } else { active = append(active, obj) } } if len(active) > 0 { if e := pause(ctx, 5*time.Second); e != nil { return Discovery{}, e } } 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 } type bluezLink struct { conn *dbus.Conn device, write 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) defer cancel() _ = b.device.CallWithContext(ctx, "org.bluez.Device1.Disconnect", 0).Err b.conn.Close() }) } func DialBlueZ(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 } 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() } }() adapter := c.Object("org.bluez", dbus.ObjectPath("/org/bluez/"+t.Adapter)) if e = adapter.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; e != nil { return nil, Target{}, e } defer stopScan(adapter) var path dbus.ObjectPath var found Target for path == "" { o, e := getObjects(ctx, c) if e != nil { return nil, Target{}, e } for p, ifs := range o { props, ok := ifs["org.bluez.Device1"] if !ok { continue } candidate, ok := advertised(props) if ok && candidate.UUID == t.UUID && candidate.Adapter == t.Adapter { if candidate.Model != t.Model { return nil, Target{}, errors.New("Sesame model changed; scan again") } connected, _ := props["Connected"].Value().(bool) if connected { return nil, Target{}, errors.New("Sesame is already connected through this adapter") } path = p found = candidate break } } if path == "" { if e = pause(ctx, 250*time.Millisecond); e != nil { return nil, Target{}, fmt.Errorf("Sesame discovery: %w", e) } } } device = c.Object("org.bluez", path) owned = true if e = device.CallWithContext(ctx, "org.bluez.Device1.Connect", 0).Err; e != nil { return nil, Target{}, e } var writePath, notifyPath dbus.ObjectPath mode := "" for writePath == "" || notifyPath == "" { o, e := getObjects(ctx, c) if e != nil { return nil, Target{}, e } 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 writePath == "" || notifyPath == "" { if e = pause(ctx, 100*time.Millisecond); e != nil { return nil, Target{}, 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, 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 = c.Object("org.bluez", notifyPath).CallWithContext(ctx, "org.bluez.GattCharacteristic1.StartNotify", 0).Err; e != nil { cancel() return nil, Target{}, e } success = true return b, found, nil }