drivers/sesame/bluez_test.go
raw ยท 7896 bytes
package sesame
import (
"bufio"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"sync"
"testing"
"time"
"github.com/godbus/dbus/v5"
)
// A private bus exercises the actual transport, without a controller or BlueZ.
type testBlueZ struct {
mu sync.Mutex
conn *dbus.Conn
objects objects
calls []string
attempts int
}
const testDevice dbus.ObjectPath = "/org/bluez/hci0/dev_FF_FF_FF_FF_FF_FF"
const testService dbus.ObjectPath = testDevice + "/service0001"
const testWrite dbus.ObjectPath = testService + "/char0002"
const testNotify dbus.ObjectPath = testService + "/char0003"
func (b *testBlueZ) GetManagedObjects() (objects, *dbus.Error) {
b.mu.Lock()
defer b.mu.Unlock()
// Copy through the D-Bus encoder before unlocking is not guaranteed, so return
// a fresh device map while other objects are immutable.
out := objects{}
for path, ifs := range b.objects {
out[path] = map[string]map[string]dbus.Variant{}
for iface, props := range ifs {
p := map[string]dbus.Variant{}
for k, v := range props {
p[k] = v
}
out[path][iface] = p
}
}
return out, nil
}
func (b *testBlueZ) SetDiscoveryFilter(filter map[string]dbus.Variant) *dbus.Error {
if value(filter, "Transport") != "le" || !reflect.DeepEqual(filter["UUIDs"].Value(), []string{ServiceUUID}) || filter["DuplicateData"].Value() != true {
return dbus.NewError("org.bluez.Error.InvalidArguments", []interface{}{"unexpected discovery filter"})
}
return nil
}
func (b *testBlueZ) StartDiscovery() *dbus.Error {
b.mu.Lock()
b.calls = append(b.calls, "scan")
b.mu.Unlock()
// Report unchanged data: a stale BlueZ entry must become eligible only here.
_ = b.conn.Emit(testDevice, "org.freedesktop.DBus.Properties.PropertiesChanged", "org.bluez.Device1", map[string]dbus.Variant{"RSSI": dbus.MakeVariant(int16(-45))}, []string{})
return nil
}
func (b *testBlueZ) StopDiscovery() *dbus.Error { return nil }
func (b *testBlueZ) Connect() *dbus.Error {
b.mu.Lock()
defer b.mu.Unlock()
b.calls = append(b.calls, "connect")
b.attempts++
if b.attempts == 1 {
return dbus.NewError("org.bluez.Error.Failed", []interface{}{"le-connection-abort-by-local"})
}
b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(true)
b.objects[testDevice]["org.bluez.Device1"]["ServicesResolved"] = dbus.MakeVariant(true)
return nil
}
func (b *testBlueZ) Disconnect() *dbus.Error {
b.mu.Lock()
defer b.mu.Unlock()
b.calls = append(b.calls, "disconnect")
b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(false)
return nil
}
func (b *testBlueZ) StartNotify() *dbus.Error {
b.mu.Lock()
b.calls = append(b.calls, "notify")
b.mu.Unlock()
// The lock may publish its initial token before StartNotify returns.
_ = b.conn.Emit(testNotify, "org.freedesktop.DBus.Properties.PropertiesChanged", "org.bluez.GattCharacteristic1", map[string]dbus.Variant{"Value": dbus.MakeVariant([]byte{3, 8, 14, 1, 2, 3, 4})}, []string{})
return nil
}
func (b *testBlueZ) StopNotify() *dbus.Error {
b.mu.Lock()
defer b.mu.Unlock()
b.calls = append(b.calls, "stop-notify")
return nil
}
func (b *testBlueZ) WriteValue(data []byte, options map[string]dbus.Variant) *dbus.Error {
if value(options, "type") != "command" {
return dbus.NewError("org.bluez.Error.Failed", []interface{}{"expected write without response"})
}
b.mu.Lock()
defer b.mu.Unlock()
b.calls = append(b.calls, "write")
return nil
}
func TestBlueZWorkflow(t *testing.T) {
daemon, e := exec.LookPath("dbus-daemon")
if e != nil {
t.Skip("dbus-daemon unavailable")
}
config := filepath.Join(t.TempDir(), "bus.conf")
if e := os.WriteFile(config, []byte(`<busconfig><type>session</type><listen>tcp:host=127.0.0.1,port=0</listen><auth>ANONYMOUS</auth><allow_anonymous/><policy context="default"><allow send_destination="*"/><allow receive_sender="*"/><allow own="*"/></policy></busconfig>`), 0600); e != nil {
t.Fatal(e)
}
cmd := exec.Command(daemon, "--config-file="+config, "--nofork", "--print-address=1")
cmd.Stderr = os.Stderr
stdout, e := cmd.StdoutPipe()
if e != nil {
t.Fatal(e)
}
if e = cmd.Start(); e != nil {
t.Fatal(e)
}
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() {
t.Fatal("no private bus address")
}
address := scanner.Text()
conn, e := dbus.Connect(address, dbus.WithAuth(dbus.AuthAnonymous()))
if e != nil {
t.Fatal(e)
}
defer conn.Close()
if _, e = conn.RequestName("org.bluez", dbus.NameFlagDoNotQueue); e != nil {
t.Fatal(e)
}
advertisement := append([]byte{21, 0, 1}, unhex("00112233445566778899aabbccddeeff")...)
b := &testBlueZ{conn: conn, objects: objects{
testDevice: {"org.bluez.Device1": {
"ManufacturerData": dbus.MakeVariant(map[uint16]dbus.Variant{0x055a: dbus.MakeVariant(advertisement)}),
"Address": dbus.MakeVariant("FF:FF:FF:FF:FF:FF"), "Adapter": dbus.MakeVariant(dbus.ObjectPath("/org/bluez/hci0")),
"Connected": dbus.MakeVariant(false), "ServicesResolved": dbus.MakeVariant(false),
}},
testService: {"org.bluez.GattService1": {"UUID": dbus.MakeVariant(ServiceUUID), "Device": dbus.MakeVariant(testDevice)}},
testWrite: {"org.bluez.GattCharacteristic1": {"UUID": dbus.MakeVariant(WriteUUID), "Service": dbus.MakeVariant(testService), "Flags": dbus.MakeVariant([]string{"write", "write-without-response"})}},
testNotify: {"org.bluez.GattCharacteristic1": {"UUID": dbus.MakeVariant(NotifyUUID), "Service": dbus.MakeVariant(testService)}},
}}
for path, iface := range map[dbus.ObjectPath]string{"/": "org.freedesktop.DBus.ObjectManager", "/org/bluez/hci0": "org.bluez.Adapter1", testDevice: "org.bluez.Device1", testWrite: "org.bluez.GattCharacteristic1", testNotify: "org.bluez.GattCharacteristic1"} {
if e := conn.Export(b, path, iface); e != nil {
t.Fatal(e)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
link, actual, e := dialSetup(ctx, testTarget, func(ctx context.Context, target Target) (Link, Target, error) {
c, err := dbus.Connect(address, dbus.WithAuth(dbus.AuthAnonymous()))
if err != nil {
return nil, Target{}, err
}
return dialBlueZConn(ctx, target, c)
})
if e != nil {
t.Fatal(e)
}
defer link.Close()
if actual.Address != "FF:FF:FF:FF:FF:FF" {
t.Fatal(actual)
}
if token, e := (&session{link: link}).initial(ctx); e != nil || !reflect.DeepEqual(token, []byte{1, 2, 3, 4}) {
t.Fatalf("initial token %x: %v", token, e)
}
if e := link.Write(ctx, []byte{3, 2}); e != nil {
t.Fatal(e)
}
link.Close()
b.mu.Lock()
defer b.mu.Unlock()
want := []string{"scan", "connect", "disconnect", "scan", "connect", "notify", "write", "stop-notify", "disconnect"}
if !reflect.DeepEqual(b.calls, want) {
t.Fatalf("calls %v; want %v", b.calls, want)
}
}
func TestSetupDoesNotRetryPermanentError(t *testing.T) {
calls := 0
expected := errors.New("already connected")
_, _, e := dialSetup(context.Background(), testTarget, func(context.Context, Target) (Link, Target, error) { calls++; return nil, Target{}, expected })
if calls != 1 || !errors.Is(e, expected) {
t.Fatal(calls, e)
}
}
func TestSetupRetryCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
_, _, e := dialSetup(ctx, testTarget, func(context.Context, Target) (Link, Target, error) {
calls++
cancel()
return nil, Target{}, &setupRetryError{context.Canceled}
})
if calls != 1 || !errors.Is(e, context.Canceled) {
t.Fatal(calls, e)
}
}
func TestSetupRetriesAreBounded(t *testing.T) {
calls := 0
expected := errors.New("connection failed")
_, _, e := dialSetup(context.Background(), testTarget, func(context.Context, Target) (Link, Target, error) {
calls++
return nil, Target{}, &setupRetryError{expected}
})
if calls != 3 || !errors.Is(e, expected) {
t.Fatal(calls, e)
}
}