penguin/homekey-go

homekey-go

commit d1f857a16419fed79811aafae3237b8e14b67529

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-07T13:14:53Z
subject0.0.10
commit d1f857a16419fed79811aafae3237b8e14b67529
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-07T13:14:53Z

    0.0.10
---
 build.env                     |  2 +-
 drivers/sesame/client.go      | 34 ++++++++++++++++++++++++----------
 drivers/sesame/client_test.go | 39 +++++++++++++++++++++++++++++++++++++++
 ha-app/CHANGELOG.md           |  4 ++++
 ha-app/config.yaml            |  2 +-
 internal/physical/manager.go  |  5 ++++-
 6 files changed, 73 insertions(+), 13 deletions(-)

diff --git a/build.env b/build.env
index a140b21..d8a01bd 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.9
+IMAGE_TAG=0.0.10
 
 APT_PROXY=
diff --git a/drivers/sesame/client.go b/drivers/sesame/client.go
index 679e2a6..59d3c86 100644
--- a/drivers/sesame/client.go
+++ b/drivers/sesame/client.go
@@ -173,17 +173,22 @@ 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 {
-	l, actual, e := dial(ctx, t)
+	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
+	l, actual, e := dial(connect, t)
+	cancel()
 	if e != nil {
-		return e
+		return fmt.Errorf("connect for Sesame registration: %w", e)
 	}
 	defer l.Close()
 	if actual.Registered {
 		return errors.New("lock already registered: import its local key instead")
 	}
 	s := &session{link: l}
-	if _, e = s.initial(ctx); e != nil {
-		return e
+	token, done := context.WithTimeout(ctx, 10*time.Second)
+	_, e = s.initial(token)
+	done()
+	if e != nil {
+		return fmt.Errorf("wait for Sesame registration initial token: %w", e)
 	}
 	private, e := ecdh.P256().GenerateKey(rand.Reader)
 	if e != nil {
@@ -193,9 +198,11 @@ func Register(ctx context.Context, t Target, dial Dialer, persist func(Credentia
 	stamp := make([]byte, 4)
 	binary.LittleEndian.PutUint32(stamp, uint32(time.Now().Unix()))
 	payload = append(payload, stamp...)
-	reply, e := s.exchange(ctx, 1, payload, false)
+	registration, finish := context.WithTimeout(ctx, 10*time.Second)
+	reply, e := s.exchange(registration, 1, payload, false)
+	finish()
 	if e != nil {
-		return e
+		return fmt.Errorf("Sesame registration exchange (lock may already be registered): %w", e)
 	}
 	if len(reply) < 77 {
 		return errors.New("invalid registration response; lock may need recovery in the Sesame app")
@@ -208,18 +215,25 @@ func Register(ctx context.Context, t Target, dial Dialer, persist func(Credentia
 	if e != nil {
 		return e
 	}
-	return persist(Credential{Secret: hex.EncodeToString(shared[:16])})
+	if e = persist(Credential{Secret: hex.EncodeToString(shared[:16])}); e != nil {
+		return fmt.Errorf("save Sesame registration key: %w", e)
+	}
+	return nil
 }
 func Probe(ctx context.Context, t Target, c Credential, dial Dialer) error {
-	l, actual, e := dial(ctx, t)
+	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
+	l, actual, e := dial(connect, t)
+	cancel()
 	if e != nil {
-		return e
+		return fmt.Errorf("connect to verify Sesame key: %w", e)
 	}
 	defer l.Close()
 	if !actual.Registered {
 		return errors.New("lock is unregistered; register it first")
 	}
-	_, e = login(ctx, l, c, nil)
+	handshake, done := context.WithTimeout(ctx, 10*time.Second)
+	_, e = login(handshake, l, c, nil)
+	done()
 	return e
 }
 
diff --git a/drivers/sesame/client_test.go b/drivers/sesame/client_test.go
index ce039c5..1652a5e 100644
--- a/drivers/sesame/client_test.go
+++ b/drivers/sesame/client_test.go
@@ -268,6 +268,45 @@ func TestInitialTokenTimeoutIdentifiesStage(t *testing.T) {
 	}
 }
 
+func TestRegistrationTimeoutIdentifiesStageAndDoesNotRetry(t *testing.T) {
+	for _, stage := range []string{"connect", "initial token", "registration exchange"} {
+		t.Run(stage, func(t *testing.T) {
+			f := newLink()
+			if stage == "initial token" {
+				<-f.packets // No initial notification arrives.
+			}
+			writes := 0
+			f.write = func(byte, []byte) error {
+				writes++ // Registration sent, but no response arrives.
+				return nil
+			}
+			dial := func(ctx context.Context, _ Target) (Link, Target, error) {
+				if stage == "connect" {
+					<-ctx.Done()
+					return nil, Target{}, ctx.Err()
+				}
+				return f, testTarget, nil
+			}
+			ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+			defer cancel()
+			e := Register(ctx, testTarget, dial, func(Credential) error {
+				t.Fatal("saved key without registration response")
+				return nil
+			})
+			if !errors.Is(e, context.DeadlineExceeded) || !strings.Contains(e.Error(), stage) {
+				t.Fatal("missing timeout stage", e)
+			}
+			wantWrites := 0
+			if stage == "registration exchange" {
+				wantWrites = 1
+			}
+			if writes != wantWrites {
+				t.Fatalf("registration attempts = %d, want %d", writes, wantWrites)
+			}
+		})
+	}
+}
+
 func TestReconnectUsesNewTokenAndResetsCipher(t *testing.T) {
 	var attempts atomic.Int32
 	firstReady := make(chan *fakeLink, 1)
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index c277c46..106d9b6 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.0.10
+
+- Better connection management and error messsages
+
 ## 0.0.9
 
 - Fixed missing remove lock button
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index de0c9ec..14989a3 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.9"
+version: "0.0.10"
 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 55e7696..6ca034f 100644
--- a/internal/physical/manager.go
+++ b/internal/physical/manager.go
@@ -287,7 +287,10 @@ func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) {
 	if in.Mode != "register" && in.Mode != "import" {
 		return Record{}, errors.New("choose register or import")
 	}
-	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	// 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)
 	defer cancel()
 	stop := context.AfterFunc(m.ctx, cancel)
 	defer stop()