drivers/sesame/bluez.go
raw ยท 12980 bytes
// 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
}
// 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 || candidate.UUID != t.UUID || candidate.Adapter != t.Adapter {
continue
}
if selected == "" || seen[path] > seen[selected] || (seen[path] == seen[selected] && string(path) < string(selected)) {
selected, found = path, candidate
}
}
return selected, found
}
// 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...)
adapter := c.Object("org.bluez", adapterPath)
if e := adapter.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; 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
started := time.Now()
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)
// Give new advertisements a chance to replace pre-reboot cached entries.
// A cache fallback still works when BlueZ suppresses duplicate reports.
if path != "" && (seen[path] > 0 || time.Since(started) >= 2*time.Second) {
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 {
return "", Target{}, errors.New("Sesame is already connected through this adapter")
}
return path, found, 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()
}
}()
path, found, e := findTarget(ctx, c, t)
if e != nil {
return nil, Target{}, 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{}, fmt.Errorf("connect Sesame %s on %s: %w", found.Address, t.Adapter, e)
}
var writePath, notifyPath dbus.ObjectPath
mode := ""
for writePath == "" || notifyPath == "" {
o, e := getObjects(ctx, c)
if e != nil {
return nil, Target{}, fmt.Errorf("discover Sesame GATT services at %s: %w", found.Address, 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{}, 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, 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{}, fmt.Errorf("subscribe to Sesame notifications at %s: %w", found.Address, e)
}
success = true
return b, found, nil
}