penguin/homekey-go

homekey-go

commit ceeba0f8b29c8934783a3e09ff834bd378140951

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-07T12:39:55Z
subjectSesame: Try fixing reconnection issue
commit ceeba0f8b29c8934783a3e09ff834bd378140951
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-07T12:39:55Z

    Sesame: Try fixing reconnection issue
---
 build.env                       |   2 +-
 drivers/sesame/README.md        |   6 +-
 drivers/sesame/bluez.go         | 151 +++++++++++++++++++++++++++++-----------
 drivers/sesame/client.go        |  20 ++++--
 drivers/sesame/client_test.go   |  64 +++++++++++++++++
 drivers/sesame/protocol_test.go |  29 ++++++++
 ha-app/CHANGELOG.md             |   5 ++
 ha-app/config.yaml              |   2 +-
 8 files changed, 231 insertions(+), 48 deletions(-)

diff --git a/build.env b/build.env
index 8d6d358..9f0146f 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.7
+IMAGE_TAG=0.0.8
 
 APT_PROXY=
diff --git a/drivers/sesame/README.md b/drivers/sesame/README.md
index 473d31f..566839e 100644
--- a/drivers/sesame/README.md
+++ b/drivers/sesame/README.md
@@ -21,7 +21,11 @@ the motor. Cloud-only guest keys are not supported.
 failure. `Action` serializes operations on that connection and waits for the
 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.
+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;
+errors identify discovery, connection, GATT services, notification subscription
+or initial-token/login waits, and connection failures are logged without keys.
 
 Commands: `lock`, `unlock`, `set-lock`, `set-unlock`, `set-boundary`. Calibration
 captures the current reported position; saving either endpoint preserves the
diff --git a/drivers/sesame/bluez.go b/drivers/sesame/bluez.go
index 43e9c37..e0d5a51 100644
--- a/drivers/sesame/bluez.go
+++ b/drivers/sesame/bluez.go
@@ -158,6 +158,112 @@ func Discover(ctx context.Context) (Discovery, error) {
 	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
@@ -203,54 +309,21 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 			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 {
+	path, found, e := findTarget(ctx, c, t)
+	if 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
+		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{}, e
+			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 {
@@ -284,7 +357,7 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 		}
 		if writePath == "" || notifyPath == "" {
 			if e = pause(ctx, 100*time.Millisecond); e != nil {
-				return nil, Target{}, e
+				return nil, Target{}, fmt.Errorf("discover Sesame GATT services at %s: %w", found.Address, e)
 			}
 		}
 	}
@@ -346,7 +419,7 @@ 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 {
 		cancel()
-		return nil, Target{}, e
+		return nil, Target{}, fmt.Errorf("subscribe to Sesame notifications at %s: %w", found.Address, e)
 	}
 	success = true
 	return b, found, nil
diff --git a/drivers/sesame/client.go b/drivers/sesame/client.go
index 03fa674..679e2a6 100644
--- a/drivers/sesame/client.go
+++ b/drivers/sesame/client.go
@@ -9,6 +9,7 @@ import (
 	"encoding/hex"
 	"errors"
 	"fmt"
+	"log"
 	"sync"
 	"time"
 )
@@ -144,7 +145,7 @@ func login(ctx context.Context, l Link, c Credential, update func(State)) (*sess
 	s := &session{link: l, update: update}
 	token, e := s.initial(ctx)
 	if e != nil {
-		return nil, e
+		return nil, fmt.Errorf("wait for Sesame initial token: %w", e)
 	}
 	key, e := c.key()
 	if e != nil {
@@ -265,6 +266,7 @@ func (c *Client) Action(ctx context.Context, name string) error {
 }
 func (c *Client) Run(ctx context.Context) {
 	defer c.set(State{Error: "physical lock stopped", Updated: time.Now()})
+	lastError := ""
 	for ctx.Err() == nil {
 		e := c.runSession(ctx)
 		msg := "physical lock disconnected"
@@ -272,28 +274,34 @@ func (c *Client) Run(ctx context.Context) {
 			msg = e.Error()
 		}
 		c.set(State{Error: msg, Updated: time.Now()})
+		if ctx.Err() == nil && msg != lastError {
+			log.Printf("Sesame %s on %s: %s; retrying in 3s", c.target.UUID, c.target.Adapter, msg)
+			lastError = msg
+		}
 		if pause(ctx, 3*time.Second) != nil {
 			return
 		}
 	}
 }
 func (c *Client) runSession(ctx context.Context) error {
-	connect, cancel := context.WithTimeout(ctx, 20*time.Second)
+	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
 	l, actual, e := c.dial(connect, c.target)
+	cancel()
 	if e != nil {
-		cancel()
 		return e
 	}
 	defer l.Close()
 	if !actual.Registered {
-		cancel()
 		return errors.New("Sesame was reset; enroll it again")
 	}
-	s, e := login(connect, l, c.credential, c.set)
-	cancel()
+	// Discovery/connect must not consume the initial-token/login budget.
+	handshake, done := context.WithTimeout(ctx, 10*time.Second)
+	s, e := login(handshake, l, c.credential, c.set)
+	done()
 	if e != nil {
 		return e
 	}
+	log.Printf("Sesame %s on %s: authenticated connection ready", c.target.UUID, c.target.Adapter)
 	for {
 		select {
 		case <-ctx.Done():
diff --git a/drivers/sesame/client_test.go b/drivers/sesame/client_test.go
index d200181..ce039c5 100644
--- a/drivers/sesame/client_test.go
+++ b/drivers/sesame/client_test.go
@@ -7,7 +7,9 @@ import (
 	"crypto/rand"
 	"encoding/hex"
 	"errors"
+	"strings"
 	"sync"
+	"sync/atomic"
 	"testing"
 	"time"
 )
@@ -255,3 +257,65 @@ func TestClientReceivesManualStateAndDoesNotReplay(t *testing.T) {
 		t.Fatal("replayed or missed unlock")
 	}
 }
+
+func TestInitialTokenTimeoutIdentifiesStage(t *testing.T) {
+	f := &fakeLink{packets: make(chan []byte), done: make(chan error)}
+	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
+	defer cancel()
+	_, e := login(ctx, f, testKey, nil)
+	if !errors.Is(e, context.DeadlineExceeded) || !strings.Contains(e.Error(), "initial token") {
+		t.Fatal("missing stage", e)
+	}
+}
+
+func TestReconnectUsesNewTokenAndResetsCipher(t *testing.T) {
+	var attempts atomic.Int32
+	firstReady := make(chan *fakeLink, 1)
+	dial := func(ctx context.Context, target Target) (Link, Target, error) {
+		n := attempts.Add(1)
+		f := newLink()
+		if n == 1 {
+			firstReady <- f
+		}
+		token := []byte{byte(n), 0x22, 0x33, 0x44}
+		<-f.packets
+		f.emit(1, append([]byte{8, 14}, token...))
+		key, _ := testKey.key()
+		peer, auth, _ := newCipher(key, token)
+		f.write = func(kind byte, b []byte) error {
+			if kind != 1 || !bytes.Equal(b, append([]byte{2}, auth[:4]...)) {
+				return errors.New("reconnect reused login token")
+			}
+			reply, _ := peer.encrypt([]byte{7, 2, 0, 0, 0, 0, 0})
+			f.emit(2, reply)
+			state, _ := peer.encrypt([]byte{8, 81, 0, 0, 0, 128, byte(n), 0, 18})
+			f.emit(2, state)
+			return nil
+		}
+		target.Registered = true
+		return f, target, nil
+	}
+	c := NewClient(testTarget, testKey, dial)
+	ctx, cancel := context.WithCancel(context.Background())
+	done := make(chan struct{})
+	go func() { c.Run(ctx); close(done) }()
+	defer func() { cancel(); <-done }()
+	await := func(position int16) {
+		deadline := time.Now().Add(5 * time.Second)
+		for time.Now().Before(deadline) {
+			s := c.State()
+			if s.Online && s.Position != nil && *s.Position == position {
+				return
+			}
+			time.Sleep(time.Millisecond)
+		}
+		t.Fatalf("did not authenticate session %d: %+v", position, c.State())
+	}
+	await(1)
+	first := <-firstReady
+	first.done <- errors.New("battery removed")
+	await(2)
+	if attempts.Load() != 2 {
+		t.Fatal("unexpected reconnect attempts", attempts.Load())
+	}
+}
diff --git a/drivers/sesame/protocol_test.go b/drivers/sesame/protocol_test.go
index 5c097b4..eeef925 100644
--- a/drivers/sesame/protocol_test.go
+++ b/drivers/sesame/protocol_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"crypto/aes"
 	"encoding/hex"
+	"github.com/godbus/dbus/v5"
 	"testing"
 )
 
@@ -148,3 +149,31 @@ func TestAdvertisement(t *testing.T) {
 		t.Fatal("short advertisement")
 	}
 }
+
+func TestDiscoveryPrefersFreshAddressForSameUUID(t *testing.T) {
+	oldPath := dbus.ObjectPath("/org/bluez/hci0/dev_00_00_00_00_00_01")
+	newPath := dbus.ObjectPath("/org/bluez/hci0/dev_FF_FF_FF_FF_FF_FF")
+	advertisement := append([]byte{21, 0, 1}, unhex("00112233445566778899aabbccddeeff")...)
+	makeProps := func(address string) map[string]dbus.Variant {
+		return map[string]dbus.Variant{
+			"ManufacturerData": dbus.MakeVariant(map[uint16]dbus.Variant{0x055a: dbus.MakeVariant(advertisement)}),
+			"Address":          dbus.MakeVariant(address), "Adapter": dbus.MakeVariant(dbus.ObjectPath("/org/bluez/hci0")),
+		}
+	}
+	o := objects{oldPath: {"org.bluez.Device1": makeProps("00:00:00:00:00:01")}, newPath: {"org.bluez.Device1": makeProps("FF:FF:FF:FF:FF:FF")}}
+	for i := 0; i < 20; i++ {
+		path, target := chooseTarget(o, testTarget, map[dbus.ObjectPath]uint64{newPath: 1})
+		if path != newPath || target.Address != "FF:FF:FF:FF:FF:FF" {
+			t.Fatal("selected stale reboot address", path, target)
+		}
+	}
+	path, _ := chooseTarget(o, testTarget, map[dbus.ObjectPath]uint64{newPath: 1, oldPath: 2})
+	if path != oldPath {
+		t.Fatal("did not use newest advertisement")
+	}
+	wrong := testTarget
+	wrong.Adapter = "hci1"
+	if path, _ := chooseTarget(o, wrong, nil); path != "" {
+		t.Fatal("crossed adapter identities")
+	}
+}
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index 23c42e0..c1e0fed 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.0.8
+
+- Sesame: Prefers fresh advertisements on reconnect
+- Sesame: Better error messages
+
 ## 0.0.7
 
 - Move configuration into Open Web UI, importing existing HA options once.
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index 11f8caf..776617f 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.7"
+version: "0.0.8"
 slug: homekey_go
 description: HomeKit virtual lock with Apple Home Key NFC authentication
 url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/