penguin/homekey-go

homekey-go

commit c541eeee0ae03b47c51dd649fa8af76830c9bc92

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-15T11:32:40Z
subjectFixed a bug that disconnect could report success after removing a failed preparation
commit c541eeee0ae03b47c51dd649fa8af76830c9bc92
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-15T11:32:40Z

    Fixed a bug that disconnect could report success after removing a failed preparation
---
 build.env                           |  2 +-
 drivers/sesame/README.md            | 12 ++++++++
 drivers/sesame/bluez.go             | 44 +++++++++++++++++++++++++--
 drivers/sesame/bluez_test.go        | 59 +++++++++++++++++++++++++++++--------
 drivers/sesame/connections.go       |  2 +-
 ha-app/CHANGELOG.md                 |  4 +++
 ha-app/config.yaml                  |  2 +-
 internal/app/physical.go            | 13 ++++----
 internal/app/physical_bluez_test.go | 59 +++++++++++++++++++++++++++++++++++++
 9 files changed, 174 insertions(+), 23 deletions(-)

diff --git a/build.env b/build.env
index 51411bd..0f0870d 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.16
+IMAGE_TAG=0.0.17
 
 APT_PROXY=
diff --git a/drivers/sesame/README.md b/drivers/sesame/README.md
index ecd23ce..6da1cb0 100644
--- a/drivers/sesame/README.md
+++ b/drivers/sesame/README.md
@@ -108,3 +108,15 @@ the old 8-second GATT budget expired. GATT now allows 45 seconds within a
 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.
+
+Existing BLE link recovery: the manager continues to reuse its live ready or
+registered session. If a new dial finds this lock already connected in BlueZ,
+there is no retained token/cipher for that link, so it disconnects only that
+UUID on the selected adapter, waits for `Connected=false`, and scans/connects
+again for a fresh initial token. A connection completing during discovery is
+recovered through the bounded setup retry path too. This does not reset the
+lock or replay registration/motor commands. Manual Disconnect always checks the
+actual BlueZ device after retiring a pending preparation, including a failed
+preparation that never acquired the link. A regression covers this
+disconnect-action cleanup, and private-bus transport tests cover delayed disconnect completion and
+connections appearing during a scan.
diff --git a/drivers/sesame/bluez.go b/drivers/sesame/bluez.go
index 0afdf8d..b719bc6 100644
--- a/drivers/sesame/bluez.go
+++ b/drivers/sesame/bluez.go
@@ -238,6 +238,30 @@ func chooseTarget(o objects, t Target, seen map[dbus.ObjectPath]uint64) (dbus.Ob
 	return selected, found
 }
 
+// disconnectDevice waits for BlueZ's link state, not just acceptance of the
+// Disconnect method. A timed-out setup can otherwise leave Connected=true and
+// make every later preparation reject the device before it can subscribe.
+func disconnectDevice(ctx context.Context, c *dbus.Conn, path dbus.ObjectPath) error {
+	cleanup, cancel := context.WithTimeout(ctx, 5*time.Second)
+	defer cancel()
+	callError := c.Object("org.bluez", path).CallWithContext(cleanup, "org.bluez.Device1.Disconnect", 0).Err
+	// A concurrent disconnect can make the method fail even though the desired
+	// state has been reached. Treat the observed link state as authoritative.
+	for {
+		o, e := getObjects(cleanup, c)
+		if e != nil {
+			return fmt.Errorf("confirm Sesame disconnect: %w", errors.Join(callError, e))
+		}
+		connected, _ := o[path]["org.bluez.Device1"]["Connected"].Value().(bool)
+		if !connected {
+			return nil
+		}
+		if e := pause(cleanup, 50*time.Millisecond); e != nil {
+			return fmt.Errorf("wait for Sesame link to disconnect: %w", errors.Join(callError, e))
+		}
+	}
+}
+
 // 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) {
@@ -259,12 +283,21 @@ func findTarget(ctx context.Context, c *dbus.Conn, t Target) (dbus.ObjectPath, T
 	if e != nil {
 		return "", Target{}, e
 	}
-	for _, ifs := range o {
+	for path, 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")
+			if candidate.Model != t.Model {
+				return "", Target{}, errors.New("Sesame model changed; scan again")
+			}
+			// Live preparations/clients never redial their own sessions. Reaching
+			// this point means this dialer has no token/cipher for the existing
+			// BlueZ link; reconnect just this device to obtain a fresh initial.
+			log.Printf("Sesame %s at %s on %s: recovering an existing BLE link before session setup", t.UUID, candidate.Address, t.Adapter)
+			if e := disconnectDevice(ctx, c, path); e != nil {
+				return "", Target{}, &setupRetryError{e}
+			}
 		}
 	}
 	adapter := c.Object("org.bluez", adapterPath)
@@ -329,7 +362,12 @@ func findTarget(ctx context.Context, c *dbus.Conn, t Target) (dbus.ObjectPath, T
 				}
 				connected, _ := o[path]["org.bluez.Device1"]["Connected"].Value().(bool)
 				if connected {
-					return "", Target{}, errors.New("Sesame is already connected through this adapter")
+					// A connection may complete while we scan (for example a
+					// late completion of an earlier canceled Connect call).
+					if e := disconnectDevice(ctx, c, path); e != nil {
+						return "", Target{}, &setupRetryError{e}
+					}
+					return "", Target{}, &setupRetryError{errors.New("recovered a BLE link established during discovery; rescan for a fresh session")}
 				}
 				return path, found, nil
 			}
diff --git a/drivers/sesame/bluez_test.go b/drivers/sesame/bluez_test.go
index 7dd6015..f8abc69 100644
--- a/drivers/sesame/bluez_test.go
+++ b/drivers/sesame/bluez_test.go
@@ -17,13 +17,16 @@ 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
-	resolutionDelay time.Duration
-	readyAt         time.Time
+	mu                sync.Mutex
+	conn              *dbus.Conn
+	objects           objects
+	calls             []string
+	attempts          int
+	resolutionDelay   time.Duration
+	readyAt           time.Time
+	disconnectDelay   time.Duration
+	disconnectAt      time.Time
+	connectDuringScan bool
 }
 
 const testDevice dbus.ObjectPath = "/org/bluez/hci0/dev_FF_FF_FF_FF_FF_FF"
@@ -34,6 +37,10 @@ const testNotify dbus.ObjectPath = testService + "/char0003"
 func (b *testBlueZ) GetManagedObjects() (objects, *dbus.Error) {
 	b.mu.Lock()
 	defer b.mu.Unlock()
+	if !b.disconnectAt.IsZero() && !time.Now().Before(b.disconnectAt) {
+		b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(false)
+		b.disconnectAt = time.Time{}
+	}
 	if !b.readyAt.IsZero() && !time.Now().Before(b.readyAt) {
 		b.objects[testDevice]["org.bluez.Device1"]["ServicesResolved"] = dbus.MakeVariant(true)
 	}
@@ -61,6 +68,10 @@ func (b *testBlueZ) SetDiscoveryFilter(filter map[string]dbus.Variant) *dbus.Err
 func (b *testBlueZ) StartDiscovery() *dbus.Error {
 	b.mu.Lock()
 	b.calls = append(b.calls, "scan")
+	if b.connectDuringScan {
+		b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(true)
+		b.connectDuringScan = false
+	}
 	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{})
@@ -71,6 +82,9 @@ func (b *testBlueZ) Connect() *dbus.Error {
 	b.mu.Lock()
 	defer b.mu.Unlock()
 	b.calls = append(b.calls, "connect")
+	if connected, _ := b.objects[testDevice]["org.bluez.Device1"]["Connected"].Value().(bool); connected {
+		return dbus.NewError("org.bluez.Error.Failed", []interface{}{"Connect called before previous link disconnected"})
+	}
 	b.attempts++
 	if b.attempts == 1 {
 		return dbus.NewError("org.bluez.Error.Failed", []interface{}{"le-connection-abort-by-local"})
@@ -84,7 +98,11 @@ 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)
+	if connected, _ := b.objects[testDevice]["org.bluez.Device1"]["Connected"].Value().(bool); connected && b.disconnectDelay > 0 {
+		b.disconnectAt = time.Now().Add(b.disconnectDelay)
+	} else {
+		b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(false)
+	}
 	return nil
 }
 func (b *testBlueZ) StartNotify() *dbus.Error {
@@ -111,10 +129,17 @@ 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) })
+	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 TestBlueZRecoversAlreadyConnectedLink(t *testing.T) {
+	testBlueZWorkflow(t, 0, "existing")
 }
-func testBlueZWorkflow(t *testing.T, resolutionDelay time.Duration) {
+func TestBlueZRecoversConnectionCompletedDuringScan(t *testing.T) {
+	testBlueZWorkflow(t, 0, "during-scan")
+}
+func testBlueZWorkflow(t *testing.T, resolutionDelay time.Duration, mode string) {
+	alreadyConnected := mode == "existing"
 	daemon, e := exec.LookPath("dbus-daemon")
 	if e != nil {
 		t.Skip("dbus-daemon unavailable")
@@ -152,12 +177,16 @@ func testBlueZWorkflow(t *testing.T, resolutionDelay time.Duration) {
 		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),
+			"Connected": dbus.MakeVariant(alreadyConnected), "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)}},
 	}}
+	if mode != "" {
+		b.disconnectDelay = 150 * time.Millisecond
+	}
+	b.connectDuringScan = mode == "during-scan"
 	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)
@@ -189,6 +218,12 @@ func testBlueZWorkflow(t *testing.T, resolutionDelay time.Duration) {
 	b.mu.Lock()
 	defer b.mu.Unlock()
 	want := []string{"scan", "connect", "disconnect", "scan", "connect", "notify", "write", "stop-notify", "disconnect"}
+	if alreadyConnected {
+		want = append([]string{"disconnect"}, want...)
+	}
+	if mode == "during-scan" {
+		want = append([]string{"scan", "disconnect"}, want...)
+	}
 	if !reflect.DeepEqual(b.calls, want) {
 		t.Fatalf("calls %v; want %v", b.calls, want)
 	}
diff --git a/drivers/sesame/connections.go b/drivers/sesame/connections.go
index 1686818..5a3598e 100644
--- a/drivers/sesame/connections.go
+++ b/drivers/sesame/connections.go
@@ -82,7 +82,7 @@ func Disconnect(ctx context.Context, adapter, address string) error {
 		if !connected {
 			return nil
 		}
-		return c.Object("org.bluez", path).CallWithContext(ctx, "org.bluez.Device1.Disconnect", 0).Err
+		return disconnectDevice(ctx, c, path)
 	}
 	return errors.New("Sesame device no longer found on this adapter; refresh connections")
 }
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index febdd57..33ccff4 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.0.17
+
+- Fixed a bug that disconnect could report success after removing a failed preparation
+
 ## 0.0.16
 
 - Follow official android app's workflow
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index 5238efa..84c784d 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.16"
+version: "0.0.17"
 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/app/physical.go b/internal/app/physical.go
index c9d08ce..af6d1ec 100644
--- a/internal/app/physical.go
+++ b/internal/app/physical.go
@@ -99,11 +99,7 @@ func physicalRoutes(mux *http.ServeMux, cfg *Settings) {
 		}
 		ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
 		defer cancel()
-		if cfg.Physical.DisconnectRegistration(r.PathValue("adapter"), r.PathValue("address")) {
-			w.WriteHeader(http.StatusNoContent)
-			return
-		}
-		if err := sesame.Disconnect(ctx, r.PathValue("adapter"), r.PathValue("address")); err != nil {
+		if err := disconnectSesame(ctx, cfg.Physical, r.PathValue("adapter"), r.PathValue("address"), sesame.Disconnect); err != nil {
 			http.Error(w, err.Error(), 409)
 			return
 		}
@@ -211,3 +207,10 @@ func physicalRoutes(mux *http.ServeMux, cfg *Settings) {
 		w.WriteHeader(http.StatusNoContent)
 	})
 }
+
+// A failed preparation may never have acquired the BlueZ link. Retiring it
+// cannot replace releasing the actual device, even when its Close succeeds.
+func disconnectSesame(ctx context.Context, m *physical.Manager, adapter, address string, release func(context.Context, string, string) error) error {
+	m.DisconnectRegistration(adapter, address)
+	return release(ctx, adapter, address)
+}
diff --git a/internal/app/physical_bluez_test.go b/internal/app/physical_bluez_test.go
new file mode 100644
index 0000000..0462622
--- /dev/null
+++ b/internal/app/physical_bluez_test.go
@@ -0,0 +1,59 @@
+package app
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"homekey.local/provisioner/drivers/sesame"
+	"homekey.local/provisioner/internal/physical"
+)
+
+// A failed preparation owns no Link. Retiring it must not short-circuit the
+// actual BlueZ disconnect, which was the reason repeated clicks were needed.
+func TestDisconnectFailedPreparationReleasesBlueZLink(t *testing.T) {
+	m, err := physical.Open(context.Background(), t.TempDir(), func(context.Context, sesame.Target) (sesame.Link, sesame.Target, error) {
+		return nil, sesame.Target{}, errors.New("Sesame is already connected through this adapter")
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer m.Close()
+	target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Address: "AA:BB:CC:DD:EE:FF"}
+	m.Prepare([]sesame.Target{target})
+	deadline := time.Now().Add(time.Second)
+	for time.Now().Before(deadline) {
+		states := m.RegistrationConnections()
+		if len(states) == 1 && states[0].Phase == "failed" {
+			break
+		}
+		time.Sleep(time.Millisecond)
+	}
+	if states := m.RegistrationConnections(); len(states) != 1 || states[0].Phase != "failed" {
+		t.Fatal("preparation did not fail", states)
+	}
+	connected := true
+	calls := 0
+	release := func(ctx context.Context, adapter, address string) error {
+		if adapter != target.Adapter || address != target.Address {
+			t.Fatal("released wrong lock", adapter, address)
+		}
+		if len(m.RegistrationConnections()) != 0 {
+			t.Fatal("released before retiring preparation")
+		}
+		calls++
+		connected = false
+		return nil
+	}
+	if err = disconnectSesame(context.Background(), m, target.Adapter, target.Address, release); err != nil {
+		t.Fatal(err)
+	}
+	if connected || calls != 1 {
+		t.Fatal("failed preparation hid the live BlueZ link", connected, calls)
+	}
+	transportError := errors.New("disconnect failed")
+	if err = disconnectSesame(context.Background(), m, target.Adapter, target.Address, func(context.Context, string, string) error { return transportError }); !errors.Is(err, transportError) {
+		t.Fatal("hid transport disconnect failure", err)
+	}
+}