penguin/homekey-go

homekey-go

commit 3e2aa88a1adf441c620f4bb08bc2a5bf661a4bcc

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-15T02:29:39Z
subjectUpdate sesame connection workflow
commit 3e2aa88a1adf441c620f4bb08bc2a5bf661a4bcc
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-15T02:29:39Z

    Update sesame connection workflow
---
 NOTICE                          |   6 +-
 build.env                       |   2 +-
 drivers/sesame/README.md        |  28 +++++-
 drivers/sesame/bluez.go         | 122 +++++++++++++++++++---
 drivers/sesame/bluez_test.go    | 218 ++++++++++++++++++++++++++++++++++++++++
 drivers/sesame/protocol_test.go |   5 +-
 ha-app/CHANGELOG.md             |   4 +
 ha-app/config.yaml              |   2 +-
 8 files changed, 366 insertions(+), 21 deletions(-)

diff --git a/NOTICE b/NOTICE
index 90c682f..83b7dde 100644
--- a/NOTICE
+++ b/NOTICE
@@ -30,8 +30,12 @@ not needed for building the library were omitted.
 
 Sesame OS3 BLE protocol and registration/calibration behavior adapted from:
   CANDY HOUSE SesameSDK_Android_with_DemoApp
-  Commit d12f23c25bffd5adf6361abd0db82bb144441875
+  Commit d12f23c (upstream 022289e5b1d082bd3f5ff685c2cf197444847a56)
   Copyright (c) 2014-2026 CANDY HOUSE
   MIT License; full notice retained in drivers/sesame/LICENSE.
 Changes: standalone Go implementation with BlueZ transport, bounded framing,
 strict session validation, private credential storage and web setup controls.
+
+Sesame BLE transport lifecycle additionally compared with CANDY HOUSE Android SDK
+mirror commit 61e2a6a (upstream 8887141f1b5482451442647ff455313b755a8655):
+LE advertisement-driven connection, GATT notification setup and teardown.
diff --git a/build.env b/build.env
index c0c03b9..cfc1006 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.13
+IMAGE_TAG=0.0.14
 
 APT_PROXY=
diff --git a/drivers/sesame/README.md b/drivers/sesame/README.md
index 566839e..e6cd5d5 100644
--- a/drivers/sesame/README.md
+++ b/drivers/sesame/README.md
@@ -23,7 +23,16 @@ lock's result code. Failed/timed-out operations are not replayed after reconnect
 `State` returns the latest immutable snapshot; pointers in snapshots must not be
 modified by callers. Each reconnect clears state until new publications arrive. Discovery prefers
 advertisements received in the current scan when BlueZ has cached multiple
-addresses for one UUID. Connection setup and login have separate deadlines;
+addresses for one UUID. Scans request LE transport, the FD81 service and duplicate
+manufacturer reports, and connection setup requires a report from the current
+scan (there is no cached-address fallback). This follows the Android SDK's
+ReceivedAdV → connectGatt(TRANSPORT_LE) → service discovery → notifications →
+initial-token/login sequence. BlueZ handles the platform-specific GATT setup.
+Connect and GATT setup each have an 8-second limit; transient setup failures
+close the failed link and rescan, up to three attempts within the 30-second
+setup deadline, plus bounded scan/disconnect cleanup. Commands and registration
+exchanges are never automatically replayed. Disconnect releases notifications
+before closing the device. Connection setup and login have separate deadlines;
 errors identify discovery, connection, GATT services, notification subscription
 or initial-token/login waits, and connection failures are logged without keys.
 
@@ -54,3 +63,20 @@ Still requires real hardware validation: local BLE registration/login, initial
 token length and response encryption, notifications during manual movement,
 calibration/motor behavior, coexistence with the phone app, and battery impact
 of a persistent connection. Unit tests do not establish those device behaviors.
+
+
+## Connection workflow reference
+
+Transport lifecycle was compared with SDK commit
+`61e2a6a` (mirror of `8887141f1b5482451442647ff455313b755a8655`), especially
+`CHBleManager.kt`, `CHSesameOS3.kt`, `CHBaseDevice.kt`, and the app's
+`BleController.kt`. The SDK also reconnects from newly received advertisements;
+its Android callbacks are mapped to BlueZ discovery reports and GATT properties
+here. Bounded transport retries are a Go/BlueZ policy, not an SDK timeout value.
+The existing local-key login algorithm already matches this SDK.
+
+The private D-Bus test runs a simulated BlueZ service and verifies LE scan
+filters, rescan after a failed connection, write-without-response selection,
+buffering of an initial token emitted during StartNotify, and StopNotify before
+Disconnect. It does not emulate controller radio behavior or establish that
+Android and a Linux USB dongle have equivalent connection reliability.
diff --git a/drivers/sesame/bluez.go b/drivers/sesame/bluez.go
index e0d5a51..6e5ad41 100644
--- a/drivers/sesame/bluez.go
+++ b/drivers/sesame/bluez.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"log"
 	"regexp"
 	"sort"
 	"strings"
@@ -112,6 +113,20 @@ func stopScan(a dbus.BusObject) {
 	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) {
 	c, e := dbus.ConnectSystemBus()
 	if e != nil {
@@ -135,7 +150,7 @@ func Discover(ctx context.Context) (Discovery, error) {
 			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 {
+		if e := startScan(ctx, obj); e != nil {
 			d.Warnings = append(d.Warnings, a.ID+": "+e.Error())
 		} else {
 			active = append(active, obj)
@@ -165,7 +180,7 @@ func chooseTarget(o objects, t Target, seen map[dbus.ObjectPath]uint64) (dbus.Ob
 	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 {
+		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)) {
@@ -192,14 +207,25 @@ func findTarget(ctx context.Context, c *dbus.Conn, t Target) (dbus.ObjectPath, T
 		return "", Target{}, e
 	}
 	defer c.RemoveMatchSignal(added...)
+	o, e := getObjects(ctx, c)
+	if e != nil {
+		return "", Target{}, e
+	}
+	for _, 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 {
+			return "", Target{}, errors.New("Sesame is already connected through this adapter")
+		}
+	}
 	adapter := c.Object("org.bluez", adapterPath)
-	if e := adapter.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; e != nil {
+	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
-	started := time.Now()
 	ticker := time.NewTicker(100 * time.Millisecond)
 	defer ticker.Stop()
 	for {
@@ -248,9 +274,8 @@ func findTarget(ctx context.Context, c *dbus.Conn, t Target) (dbus.ObjectPath, T
 				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) {
+			// 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")
 				}
@@ -267,6 +292,7 @@ func findTarget(ctx context.Context, c *dbus.Conn, t Target) (dbus.ObjectPath, T
 type bluezLink struct {
 	conn          *dbus.Conn
 	device, write dbus.BusObject
+	notify        dbus.BusObject
 	mode          string
 	packets       chan []byte
 	done          chan error
@@ -283,12 +309,57 @@ func (b *bluezLink) Close() {
 	b.once.Do(func() {
 		b.cancel()
 		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
-		defer cancel()
+		// 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, 30*time.Second)
+	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
 	}
@@ -296,6 +367,11 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 	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
@@ -315,15 +391,25 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 	}
 	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)
+	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, 8*time.Second)
+	defer cancelGATT()
 	var writePath, notifyPath dbus.ObjectPath
 	mode := ""
 	for writePath == "" || notifyPath == "" {
-		o, e := getObjects(ctx, c)
+		o, e := getObjects(gatt, c)
 		if e != nil {
-			return nil, Target{}, fmt.Errorf("discover Sesame GATT services at %s: %w", found.Address, e)
+			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 {
@@ -355,9 +441,12 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 				}
 			}
 		}
+		if resolved && (writePath == "" || notifyPath == "") {
+			return nil, Target{}, errors.New("Sesame GATT services resolved without command/notification characteristics")
+		}
 		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 e = pause(gatt, 100*time.Millisecond); e != nil {
+				return nil, Target{}, &setupRetryError{fmt.Errorf("discover Sesame GATT services at %s: %w", found.Address, e)}
 			}
 		}
 	}
@@ -370,7 +459,7 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 		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}
+	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 {
@@ -417,10 +506,11 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 			}
 		}
 	}()
-	if e = c.Object("org.bluez", notifyPath).CallWithContext(ctx, "org.bluez.GattCharacteristic1.StartNotify", 0).Err; e != nil {
+	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
 }
diff --git a/drivers/sesame/bluez_test.go b/drivers/sesame/bluez_test.go
new file mode 100644
index 0000000..a5565a1
--- /dev/null
+++ b/drivers/sesame/bluez_test.go
@@ -0,0 +1,218 @@
+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)
+	}
+}
diff --git a/drivers/sesame/protocol_test.go b/drivers/sesame/protocol_test.go
index eeef925..3ec7f42 100644
--- a/drivers/sesame/protocol_test.go
+++ b/drivers/sesame/protocol_test.go
@@ -171,9 +171,12 @@ func TestDiscoveryPrefersFreshAddressForSameUUID(t *testing.T) {
 	if path != oldPath {
 		t.Fatal("did not use newest advertisement")
 	}
+	if path, _ := chooseTarget(o, testTarget, nil); path != "" {
+		t.Fatal("selected a cached address without a current advertisement")
+	}
 	wrong := testTarget
 	wrong.Adapter = "hci1"
-	if path, _ := chooseTarget(o, wrong, nil); path != "" {
+	if path, _ := chooseTarget(o, wrong, map[dbus.ObjectPath]uint64{newPath: 1}); path != "" {
 		t.Fatal("crossed adapter identities")
 	}
 }
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index f46e50d..7c908ee 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.0.14
+
+- Update sesame connection workflow
+
 ## 0.0.13
 
 - Added manual disconnect for sesame locks
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index cfb4ab0..d339af4 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.13"
+version: "0.0.14"
 slug: homekey_go
 description: HomeKit virtual lock with Apple Home Key NFC authentication
 url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/