penguin/homekey-go

homekey-go

drivers/sesame/bluez.go

raw ยท 9705 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
}

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
}