penguin/homekey-go

homekey-go

commit e2a37ce0126d3c21cfe1f0243ade0bb14c580ec9

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-15T03:07:01Z
subjectExtend exchange timeout for sesame
commit e2a37ce0126d3c21cfe1f0243ade0bb14c580ec9
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-15T03:07:01Z

    Extend exchange timeout for sesame
---
 build.env                     |  2 +-
 drivers/sesame/README.md      | 12 ++++++++++--
 drivers/sesame/bluez.go       | 12 ++++++++++--
 drivers/sesame/bluez_test.go  | 26 ++++++++++++++++++--------
 drivers/sesame/client.go      |  6 +++---
 drivers/sesame/client_test.go | 31 +++++++++++++++++++++++++++++++
 ha-app/CHANGELOG.md           |  4 ++++
 ha-app/config.yaml            |  2 +-
 internal/physical/manager.go  |  2 +-
 9 files changed, 79 insertions(+), 18 deletions(-)

diff --git a/build.env b/build.env
index cfc1006..fe9401c 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.14
+IMAGE_TAG=0.0.15
 
 APT_PROXY=
diff --git a/drivers/sesame/README.md b/drivers/sesame/README.md
index e6cd5d5..5185f8a 100644
--- a/drivers/sesame/README.md
+++ b/drivers/sesame/README.md
@@ -28,8 +28,8 @@ 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
+Connect has an 8-second limit and GATT setup has a 45-second limit; transient setup failures
+close the failed link and rescan, up to three attempts within the 60-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;
@@ -80,3 +80,11 @@ 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.
+
+A real Sesame 6 Pro trace showed successful ATT responses roughly one second
+apart at a 500 ms connection interval, with discovery still progressing when
+the old 8-second GATT budget expired. GATT now allows 45 seconds within a
+60-second scan/connect/setup budget. Enrollment allows 90 seconds overall so
+setup does not consume the separate initial-token and registration budgets.
+Login and command timeouts are unchanged. The private-bus regression test also
+simulates discovery lasting longer than eight seconds.
diff --git a/drivers/sesame/bluez.go b/drivers/sesame/bluez.go
index 6e5ad41..3230172 100644
--- a/drivers/sesame/bluez.go
+++ b/drivers/sesame/bluez.go
@@ -15,6 +15,14 @@ import (
 	"github.com/godbus/dbus/v5"
 )
 
+// A capture from a Sesame 6 Pro showed valid discovery responses about one
+// second apart on a 500 ms connection interval. Eight seconds aborts discovery
+// before BlueZ can finish enumerating characteristics and descriptors.
+const (
+	setupTimeout = 60 * time.Second
+	gattTimeout  = 45 * time.Second
+)
+
 type Link interface {
 	Write(context.Context, []byte) error
 	Packets() <-chan []byte
@@ -354,7 +362,7 @@ func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
 		return nil, Target{}, e
 	}
 	// Also bound callers which do not supply the normal Client/Probe deadline.
-	setup, cancel := context.WithTimeout(ctx, 30*time.Second)
+	setup, cancel := context.WithTimeout(ctx, setupTimeout)
 	defer cancel()
 	return dialSetup(setup, t, dialBlueZOnce)
 }
@@ -398,7 +406,7 @@ func dialBlueZConn(ctx context.Context, t Target, c *dbus.Conn) (Link, Target, e
 		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)
+	gatt, cancelGATT := context.WithTimeout(ctx, gattTimeout)
 	defer cancelGATT()
 	var writePath, notifyPath dbus.ObjectPath
 	mode := ""
diff --git a/drivers/sesame/bluez_test.go b/drivers/sesame/bluez_test.go
index a5565a1..7dd6015 100644
--- a/drivers/sesame/bluez_test.go
+++ b/drivers/sesame/bluez_test.go
@@ -17,11 +17,13 @@ import (
 
 // 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
+	mu              sync.Mutex
+	conn            *dbus.Conn
+	objects         objects
+	calls           []string
+	attempts        int
+	resolutionDelay time.Duration
+	readyAt         time.Time
 }
 
 const testDevice dbus.ObjectPath = "/org/bluez/hci0/dev_FF_FF_FF_FF_FF_FF"
@@ -32,6 +34,9 @@ const testNotify dbus.ObjectPath = testService + "/char0003"
 func (b *testBlueZ) GetManagedObjects() (objects, *dbus.Error) {
 	b.mu.Lock()
 	defer b.mu.Unlock()
+	if !b.readyAt.IsZero() && !time.Now().Before(b.readyAt) {
+		b.objects[testDevice]["org.bluez.Device1"]["ServicesResolved"] = dbus.MakeVariant(true)
+	}
 	// Copy through the D-Bus encoder before unlocking is not guaranteed, so return
 	// a fresh device map while other objects are immutable.
 	out := objects{}
@@ -71,7 +76,8 @@ func (b *testBlueZ) Connect() *dbus.Error {
 		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)
+	b.readyAt = time.Now().Add(b.resolutionDelay)
+	b.objects[testDevice]["org.bluez.Device1"]["ServicesResolved"] = dbus.MakeVariant(false)
 	return nil
 }
 func (b *testBlueZ) Disconnect() *dbus.Error {
@@ -105,6 +111,10 @@ func (b *testBlueZ) WriteValue(data []byte, options map[string]dbus.Variant) *db
 	return nil
 }
 func TestBlueZWorkflow(t *testing.T) {
+	t.Run("immediate services", func(t *testing.T) { testBlueZWorkflow(t, 0) })
+	t.Run("discovery exceeds old eight-second limit", func(t *testing.T) { testBlueZWorkflow(t, 8500*time.Millisecond) })
+}
+func testBlueZWorkflow(t *testing.T, resolutionDelay time.Duration) {
 	daemon, e := exec.LookPath("dbus-daemon")
 	if e != nil {
 		t.Skip("dbus-daemon unavailable")
@@ -138,7 +148,7 @@ func TestBlueZWorkflow(t *testing.T) {
 		t.Fatal(e)
 	}
 	advertisement := append([]byte{21, 0, 1}, unhex("00112233445566778899aabbccddeeff")...)
-	b := &testBlueZ{conn: conn, objects: objects{
+	b := &testBlueZ{conn: conn, resolutionDelay: resolutionDelay, 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")),
@@ -153,7 +163,7 @@ func TestBlueZWorkflow(t *testing.T) {
 			t.Fatal(e)
 		}
 	}
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	ctx, cancel := context.WithTimeout(context.Background(), 15*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()))
diff --git a/drivers/sesame/client.go b/drivers/sesame/client.go
index 59d3c86..84b20bd 100644
--- a/drivers/sesame/client.go
+++ b/drivers/sesame/client.go
@@ -173,7 +173,7 @@ func login(ctx context.Context, l Link, c Credential, update func(State)) (*sess
 // immediately after deriving the credential, before any additional BLE command.
 // A registered device must instead be imported; this API never resets a lock.
 func Register(ctx context.Context, t Target, dial Dialer, persist func(Credential) error) error {
-	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
+	connect, cancel := context.WithTimeout(ctx, setupTimeout)
 	l, actual, e := dial(connect, t)
 	cancel()
 	if e != nil {
@@ -221,7 +221,7 @@ func Register(ctx context.Context, t Target, dial Dialer, persist func(Credentia
 	return nil
 }
 func Probe(ctx context.Context, t Target, c Credential, dial Dialer) error {
-	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
+	connect, cancel := context.WithTimeout(ctx, setupTimeout)
 	l, actual, e := dial(connect, t)
 	cancel()
 	if e != nil {
@@ -298,7 +298,7 @@ func (c *Client) Run(ctx context.Context) {
 	}
 }
 func (c *Client) runSession(ctx context.Context) error {
-	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
+	connect, cancel := context.WithTimeout(ctx, setupTimeout)
 	l, actual, e := c.dial(connect, c.target)
 	cancel()
 	if e != nil {
diff --git a/drivers/sesame/client_test.go b/drivers/sesame/client_test.go
index 1652a5e..9f6f08c 100644
--- a/drivers/sesame/client_test.go
+++ b/drivers/sesame/client_test.go
@@ -358,3 +358,34 @@ func TestReconnectUsesNewTokenAndResetsCipher(t *testing.T) {
 		t.Fatal("unexpected reconnect attempts", attempts.Load())
 	}
 }
+
+// A longer transport timeout is ineffective if any session entry point still
+// wraps it in the old 30-second context.
+func TestSessionEntryPointsAllowSlowDiscovery(t *testing.T) {
+	expected := errors.New("stop before handshake")
+	for _, name := range []string{"register", "probe", "client"} {
+		t.Run(name, func(t *testing.T) {
+			called := false
+			dial := func(ctx context.Context, target Target) (Link, Target, error) {
+				called = true
+				deadline, ok := ctx.Deadline()
+				if !ok || time.Until(deadline) < 59*time.Second || time.Until(deadline) > 60*time.Second {
+					t.Errorf("unexpected setup budget: %v", time.Until(deadline))
+				}
+				return nil, Target{}, expected
+			}
+			var err error
+			switch name {
+			case "register":
+				err = Register(context.Background(), testTarget, dial, func(Credential) error { t.Fatal("unexpected registration"); return nil })
+			case "probe":
+				err = Probe(context.Background(), testTarget, testKey, dial)
+			case "client":
+				err = NewClient(testTarget, testKey, dial).runSession(context.Background())
+			}
+			if !called || !errors.Is(err, expected) {
+				t.Fatal(called, err)
+			}
+		})
+	}
+}
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index 7c908ee..1590d7e 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.0.15
+
+- Extend GATT timeout
+
 ## 0.0.14
 
 - Update sesame connection workflow
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index d339af4..e27a8b8 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.14"
+version: "0.0.15"
 slug: homekey_go
 description: HomeKit virtual lock with Apple Home Key NFC authentication
 url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/
diff --git a/internal/physical/manager.go b/internal/physical/manager.go
index 6ca034f..17c4566 100644
--- a/internal/physical/manager.go
+++ b/internal/physical/manager.go
@@ -290,7 +290,7 @@ func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) {
 	// Leave room for separate BLE discovery/connect, initial-token and
 	// registration budgets, plus persistence. A slow dial must not exhaust
 	// the handshake deadline before the lock can answer.
-	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
+	ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
 	defer cancel()
 	stop := context.AfterFunc(m.ctx, cancel)
 	defer stop()