commit ceeba0f8b29c8934783a3e09ff834bd378140951
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-07T12:39:55Z |
| subject | Sesame: 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/