penguin/homekey-go

homekey-go

commit c893a65e99c2d0da3110bca8e6a3353f3076bafb

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-15T05:25:28Z
subjectUpdate sesame connection workflow again
commit c893a65e99c2d0da3110bca8e6a3353f3076bafb
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-15T05:25:28Z

    Update sesame connection workflow again
---
 build.env                              |   2 +-
 drivers/sesame/README.md               |  24 +++-
 drivers/sesame/bluez.go                |  44 +++++++-
 drivers/sesame/client.go               |  86 +++++++++++++--
 drivers/sesame/registration.go         | 163 +++++++++++++++++++++++++++
 drivers/sesame/registration_test.go    | 194 +++++++++++++++++++++++++++++++++
 ha-app/CHANGELOG.md                    |   4 +
 ha-app/DOCS.md                         |  19 ++--
 ha-app/config.yaml                     |   2 +-
 internal/app/page.html                 |  56 ++++++----
 internal/app/physical.go               |  24 ++++
 internal/app/physical_test.go          |   2 +-
 internal/physical/manager.go           |  45 +++++++-
 internal/physical/registration.go      |  80 ++++++++++++++
 internal/physical/registration_test.go | 193 ++++++++++++++++++++++++++++++++
 15 files changed, 886 insertions(+), 52 deletions(-)

diff --git a/build.env b/build.env
index fe9401c..51411bd 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.15
+IMAGE_TAG=0.0.16
 
 APT_PROXY=
diff --git a/drivers/sesame/README.md b/drivers/sesame/README.md
index 5185f8a..ecd23ce 100644
--- a/drivers/sesame/README.md
+++ b/drivers/sesame/README.md
@@ -11,8 +11,25 @@ The protocol is adapted from the MIT-licensed CANDY HOUSE Android SDK at
 
 `Discover` scans powered local BlueZ adapters. Devices are matched by their
 advertised Sesame UUID and supported product ID, not by a friendly name or fixed
-Bluetooth MAC. `Register` enrolls an unregistered device with P-256 ECDH and calls
-a persistence callback as soon as the 16-byte credential is available. It never
+Bluetooth MAC. The web UI scans on page load and on **Scan for locks**. During
+that scan, `DiscoverWithCallback` starts `PrepareRegistration` for unregistered
+locks before the scan finishes. Each preparation connects, subscribes, receives
+the initial token, and retains the link in **Ready to register** without sending
+a registration command. Repeated scans reuse preparations and deduplicate a
+physical UUID across adapters. **Bluetooth connections** lists these preparations
+alongside all connected Sesame links; Register is enabled only for a ready link.
+The separate **Register** button opens a name/confirmation form. Submitting it
+uses that same link and original token; it never dials a second connection.
+The 10-second registration deadline starts at submission, not while waiting for
+the user. Disconnect and service shutdown release waiting links. Failed
+registration is never replayed automatically; scan again to prepare a new link.
+
+Registration uses P-256 ECDH and calls a persistence callback as soon as the
+16-byte credential is available. It then loads status/settings from the reply,
+creates the cipher from the original token and hands the live session to
+`Client.Run`, with no disconnect or additional login. If the registry cannot be
+saved, the handoff is closed and the saved key remains available for recovery.
+The standalone `Register` helper remains available for one-shot callers. It never
 issues a reset. Registration here provisions BLE access, not a Sesame cloud
 account. `Probe` validates an imported local key by logging in without moving
 the motor. Cloud-only guest keys are not supported.
@@ -58,6 +75,9 @@ Run `go test -race ./...` and `go vet ./...` at repository root. Tests cover RFC
 4493 CMAC vectors, independently generated OpenSSL/Python CCM vectors with high
 counter bytes, tampering, framing, P-256 registration, commands, calibration,
 unsupported boundary settings, cancellation, private storage and HTTP validation.
+Prepared-registration tests verify no write before confirmation, original-token
+cipher use for subsequent encrypted commands/notifications, one dial across
+registration, duplicate scans, disconnects, timeouts and registry-write cleanup.
 
 Still requires real hardware validation: local BLE registration/login, initial
 token length and response encryption, notifications during manual movement,
diff --git a/drivers/sesame/bluez.go b/drivers/sesame/bluez.go
index 3230172..0afdf8d 100644
--- a/drivers/sesame/bluez.go
+++ b/drivers/sesame/bluez.go
@@ -136,6 +136,13 @@ func startScan(ctx context.Context, a dbus.BusObject) error {
 	return a.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err
 }
 func Discover(ctx context.Context) (Discovery, error) {
+	return DiscoverWithCallback(ctx, nil)
+}
+
+// DiscoverWithCallback reports candidates during the scan, allowing the UI's
+// registration links to start connecting before the scan finishes. DialBlueZ
+// still requires a fresh advertisement before opening any candidate connection.
+func DiscoverWithCallback(ctx context.Context, found func([]Target)) (Discovery, error) {
 	c, e := dbus.ConnectSystemBus()
 	if e != nil {
 		return Discovery{}, fmt.Errorf("Bluetooth unavailable: %w", e)
@@ -165,8 +172,41 @@ func Discover(ctx context.Context) (Discovery, error) {
 		}
 	}
 	if len(active) > 0 {
-		if e := pause(ctx, 5*time.Second); e != nil {
-			return Discovery{}, e
+		if found == nil {
+			if e := pause(ctx, 5*time.Second); e != nil {
+				return Discovery{}, e
+			}
+		} else {
+			ticker := time.NewTicker(100 * time.Millisecond)
+			defer ticker.Stop()
+			deadline := time.NewTimer(5 * time.Second)
+			defer deadline.Stop()
+			seen := map[string]bool{}
+		scan:
+			for {
+				select {
+				case <-ctx.Done():
+					return Discovery{}, ctx.Err()
+				case <-deadline.C:
+					break scan
+				case <-ticker.C:
+					current, err := getObjects(ctx, c)
+					if err != nil {
+						return Discovery{}, err
+					}
+					var candidates []Target
+					for _, t := range snapshot(current).Locks {
+						id := t.UUID // One preparation per physical lock during this scan.
+						if !seen[id] {
+							seen[id] = true
+							candidates = append(candidates, t)
+						}
+					}
+					if len(candidates) > 0 {
+						found(candidates)
+					}
+				}
+			}
 		}
 	}
 	o, e = getObjects(ctx, c)
diff --git a/drivers/sesame/client.go b/drivers/sesame/client.go
index 84b20bd..cc1ad14 100644
--- a/drivers/sesame/client.go
+++ b/drivers/sesame/client.go
@@ -185,14 +185,20 @@ func Register(ctx context.Context, t Target, dial Dialer, persist func(Credentia
 	}
 	s := &session{link: l}
 	token, done := context.WithTimeout(ctx, 10*time.Second)
-	_, e = s.initial(token)
+	initial, e := s.initial(token)
 	done()
 	if e != nil {
 		return fmt.Errorf("wait for Sesame registration initial token: %w", e)
 	}
+	_, e = s.register(ctx, initial, persist)
+	return e
+}
+
+// register uses the live unregistered link and its original initial token.
+func (s *session) register(ctx context.Context, token []byte, persist func(Credential) error) (Credential, error) {
 	private, e := ecdh.P256().GenerateKey(rand.Reader)
 	if e != nil {
-		return e
+		return Credential{}, e
 	}
 	payload := append([]byte(nil), private.PublicKey().Bytes()[1:]...)
 	stamp := make([]byte, 4)
@@ -202,24 +208,39 @@ func Register(ctx context.Context, t Target, dial Dialer, persist func(Credentia
 	reply, e := s.exchange(registration, 1, payload, false)
 	finish()
 	if e != nil {
-		return fmt.Errorf("Sesame registration exchange (lock may already be registered): %w", e)
+		return Credential{}, 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")
+		return Credential{}, errors.New("invalid registration response; lock may need recovery in the Sesame app")
 	}
 	pub, e := ecdh.P256().NewPublicKey(append([]byte{4}, reply[13:77]...))
 	if e != nil {
-		return errors.New("invalid Sesame registration public key")
+		return Credential{}, errors.New("invalid Sesame registration public key")
 	}
 	shared, e := private.ECDH(pub)
 	if e != nil {
-		return e
+		return Credential{}, e
 	}
-	if e = persist(Credential{Secret: hex.EncodeToString(shared[:16])}); e != nil {
-		return fmt.Errorf("save Sesame registration key: %w", e)
+	credential := Credential{Secret: hex.EncodeToString(shared[:16])}
+	if e = persist(credential); e != nil {
+		return Credential{}, fmt.Errorf("save Sesame registration key: %w", e)
 	}
-	return nil
+	s.cipher, _, e = newCipher(shared[:16], token)
+	if e != nil {
+		return Credential{}, e
+	}
+	// Keep the SDK's registration status/settings and authenticated session.
+	if e = s.state.publish(81, reply[:7]); e != nil {
+		return Credential{}, e
+	}
+	if e = s.state.publish(80, reply[7:13]); e != nil {
+		return Credential{}, e
+	}
+	s.state.Online = true
+	s.publish()
+	return credential, nil
 }
+
 func Probe(ctx context.Context, t Target, c Credential, dial Dialer) error {
 	connect, cancel := context.WithTimeout(ctx, setupTimeout)
 	l, actual, e := dial(connect, t)
@@ -249,6 +270,9 @@ type Client struct {
 	ops        chan operation
 	mu         sync.RWMutex
 	state      State
+	initial    *session
+	cancel     context.CancelFunc
+	closed     bool
 }
 
 func NewClient(t Target, c Credential, dial Dialer) *Client {
@@ -278,7 +302,32 @@ func (c *Client) Action(ctx context.Context, name string) error {
 		return ctx.Err()
 	}
 }
+
+// Close releases an unstarted registration handoff or cancels a running client.
+func (c *Client) Close() {
+	c.mu.Lock()
+	c.closed = true
+	initial, cancel := c.initial, c.cancel
+	c.initial = nil
+	c.mu.Unlock()
+	if cancel != nil {
+		cancel()
+	}
+	if initial != nil {
+		initial.link.Close()
+	}
+}
 func (c *Client) Run(ctx context.Context) {
+	defer c.Close()
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+	c.mu.Lock()
+	if c.closed {
+		c.mu.Unlock()
+		return
+	}
+	c.cancel = cancel
+	c.mu.Unlock()
 	defer c.set(State{Error: "physical lock stopped", Updated: time.Now()})
 	lastError := ""
 	for ctx.Err() == nil {
@@ -298,6 +347,16 @@ func (c *Client) Run(ctx context.Context) {
 	}
 }
 func (c *Client) runSession(ctx context.Context) error {
+	c.mu.Lock()
+	initial := c.initial
+	c.initial = nil
+	c.mu.Unlock()
+	if initial != nil {
+		defer initial.link.Close()
+		initial.update = c.set
+		initial.publish()
+		return c.serve(ctx, initial)
+	}
 	connect, cancel := context.WithTimeout(ctx, setupTimeout)
 	l, actual, e := c.dial(connect, c.target)
 	cancel()
@@ -315,6 +374,11 @@ func (c *Client) runSession(ctx context.Context) error {
 	if e != nil {
 		return e
 	}
+	return c.serve(ctx, s)
+}
+
+func (c *Client) serve(ctx context.Context, s *session) error {
+	l := s.link
 	log.Printf("Sesame %s on %s: authenticated connection ready", c.target.UUID, c.target.Adapter)
 	for {
 		select {
@@ -336,14 +400,14 @@ func (c *Client) runSession(ctx context.Context) error {
 				}
 			}
 		case op := <-c.ops:
-			if e = op.ctx.Err(); e != nil {
+			if e := op.ctx.Err(); e != nil {
 				op.reply <- e
 				continue
 			}
 			command, cancel := context.WithTimeout(op.ctx, 8*time.Second)
 			// Service shutdown also cancels an in-flight web/HAP operation.
 			stop := context.AfterFunc(ctx, cancel)
-			e = s.action(command, op.name)
+			e := s.action(command, op.name)
 			stop()
 			cancel()
 			op.reply <- e
diff --git a/drivers/sesame/registration.go b/drivers/sesame/registration.go
new file mode 100644
index 0000000..b346656
--- /dev/null
+++ b/drivers/sesame/registration.go
@@ -0,0 +1,163 @@
+package sesame
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log"
+	"sync"
+	"time"
+)
+
+// RegistrationState never exposes a token or key. Connected means GATT is ready;
+// Ready means the initial notification has arrived and Register may be clicked.
+type RegistrationState struct {
+	Target    Target `json:"target"`
+	Phase     string `json:"phase"`
+	Connected bool   `json:"connected"`
+	Error     string `json:"error,omitempty"`
+}
+
+type registrationResult struct {
+	client *Client
+	err    error
+}
+type registrationRequest struct {
+	ctx     context.Context
+	persist func(Credential) error
+	reply   chan registrationResult
+}
+
+// RegistrationConnection owns an unregistered connection while the user decides
+// whether to register. Only its worker reads notifications or writes commands.
+type RegistrationConnection struct {
+	ctx      context.Context
+	cancel   context.CancelFunc
+	mu       sync.RWMutex
+	state    RegistrationState
+	requests chan registrationRequest
+	done     chan struct{}
+}
+
+func PrepareRegistration(ctx context.Context, target Target, dial Dialer) *RegistrationConnection {
+	life, cancel := context.WithCancel(ctx)
+	p := &RegistrationConnection{ctx: life, cancel: cancel, state: RegistrationState{Target: target, Phase: "connecting"}, requests: make(chan registrationRequest), done: make(chan struct{})}
+	go p.run(target, dial)
+	return p
+}
+func (p *RegistrationConnection) State() RegistrationState {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	return p.state
+}
+func (p *RegistrationConnection) set(target Target, phase string, connected bool, err error) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	p.state = RegistrationState{Target: target, Phase: phase, Connected: connected}
+	if err != nil {
+		p.state.Error = err.Error()
+	}
+	if err != nil {
+		log.Printf("Sesame %s on %s: registration %s: %v", target.UUID, target.Adapter, phase, err)
+	} else {
+		log.Printf("Sesame %s on %s: registration %s", target.UUID, target.Adapter, phase)
+	}
+}
+func (p *RegistrationConnection) Close() { p.cancel(); <-p.done }
+
+// Register never dials. Once accepted, wait for the bounded exchange to finish,
+// even if the HTTP request is canceled, so a live client cannot be orphaned.
+func (p *RegistrationConnection) Register(ctx context.Context, persist func(Credential) error) (*Client, error) {
+	if p.State().Phase != "ready" {
+		return nil, errors.New("lock is not ready to register; wait for its initial token or scan again")
+	}
+	req := registrationRequest{ctx: ctx, persist: persist, reply: make(chan registrationResult, 1)}
+	select {
+	case <-ctx.Done():
+		return nil, ctx.Err()
+	case <-p.done:
+		return nil, errors.New("registration connection ended; scan again")
+	case p.requests <- req:
+	}
+	result := <-req.reply
+	return result.client, result.err
+}
+
+func (p *RegistrationConnection) run(target Target, dial Dialer) {
+	defer close(p.done)
+	defer p.cancel()
+	setup, cancel := context.WithTimeout(p.ctx, setupTimeout)
+	l, actual, err := dial(setup, target)
+	cancel()
+	if err != nil {
+		p.set(target, "failed", false, fmt.Errorf("connect for Sesame registration: %w", err))
+		return
+	}
+	handedOff := false
+	defer func() {
+		if !handedOff {
+			l.Close()
+		}
+	}()
+	target = actual
+	if actual.Registered {
+		p.set(target, "failed", false, errors.New("lock already registered: import its local key instead"))
+		return
+	}
+	p.set(target, "waiting-token", true, nil)
+	s := &session{link: l}
+	initial, done := context.WithTimeout(p.ctx, 10*time.Second)
+	token, err := s.initial(initial)
+	done()
+	if err != nil {
+		p.set(target, "failed", false, fmt.Errorf("wait for Sesame registration initial token: %w", err))
+		return
+	}
+	p.set(target, "ready", true, nil)
+	for {
+		select {
+		case <-p.ctx.Done():
+			p.set(target, "disconnected", false, p.ctx.Err())
+			return
+		case err := <-l.Done():
+			if err == nil {
+				err = errors.New("Sesame connection closed")
+			}
+			p.set(target, "disconnected", false, err)
+			return
+		case fragment, ok := <-l.Packets():
+			if !ok {
+				p.set(target, "disconnected", false, errors.New("Sesame notifications closed"))
+				return
+			}
+			payload, err := s.decode(fragment)
+			if err == nil && payload != nil {
+				err = s.handle(payload)
+			}
+			if err != nil {
+				p.set(target, "failed", false, err)
+				return
+			}
+		case req := <-p.requests:
+			p.set(target, "registering", true, nil)
+			command, finish := context.WithTimeout(req.ctx, 10*time.Second)
+			stop := context.AfterFunc(p.ctx, finish)
+			credential, err := s.register(command, token, req.persist)
+			stop()
+			finish()
+			if err != nil {
+				p.set(target, "failed", false, err)
+				req.reply <- registrationResult{err: err}
+				return // Never replay a possibly committed registration.
+			}
+			target.Registered = true
+			client := NewClient(target, credential, dial)
+			client.initial = s
+			client.state = s.state
+			p.set(target, "registered", true, nil)
+			handedOff = true
+			req.reply <- registrationResult{client: client}
+			return
+		}
+	}
+}
diff --git a/drivers/sesame/registration_test.go b/drivers/sesame/registration_test.go
new file mode 100644
index 0000000..43123bb
--- /dev/null
+++ b/drivers/sesame/registration_test.go
@@ -0,0 +1,194 @@
+package sesame
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdh"
+	"crypto/rand"
+	"encoding/hex"
+	"errors"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func waitRegistration(t *testing.T, p *RegistrationConnection, phase string) {
+	t.Helper()
+	deadline := time.Now().Add(time.Second)
+	for time.Now().Before(deadline) {
+		if p.State().Phase == phase {
+			return
+		}
+		time.Sleep(time.Millisecond)
+	}
+	t.Fatalf("registration phase = %+v, want %s", p.State(), phase)
+}
+func linkClosed(f *fakeLink) bool { f.mu.Lock(); defer f.mu.Unlock(); return f.closed }
+
+func TestPreparedRegistrationWaitsAndHandsOffOriginalSession(t *testing.T) {
+	f := newLink()
+	device, err := ecdh.P256().GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var peer *sessionCipher
+	var registrations, logins, dials atomic.Int32
+	var expectedSecret string
+	f.write = func(kind byte, payload []byte) error {
+		if kind == 1 {
+			if payload[0] == 2 {
+				logins.Add(1)
+				return errors.New("registration must not login again")
+			}
+			if len(payload) != 69 || payload[0] != 1 {
+				return errors.New("bad registration")
+			}
+			registrations.Add(1)
+			pub, err := ecdh.P256().NewPublicKey(append([]byte{4}, payload[1:65]...))
+			if err != nil {
+				return err
+			}
+			shared, err := device.ECDH(pub)
+			if err != nil {
+				return err
+			}
+			expectedSecret = hex.EncodeToString(shared[:16])
+			peer, _, err = newCipher(shared[:16], unhex("11223344"))
+			if err != nil {
+				return err
+			}
+			reply := make([]byte, 77)
+			copy(reply, []byte{0, 0, 0, 128, 45, 0, 18})
+			copy(reply[7:], []byte{90, 0, 0, 0, 0, 0})
+			copy(reply[13:], device.PublicKey().Bytes()[1:])
+			f.emit(1, append([]byte{7, 1, 0}, reply...))
+			return nil
+		}
+		plain, err := peer.decrypt(payload)
+		if err != nil {
+			return err
+		}
+		if !bytes.Equal(plain, []byte{83, 0, 14}) {
+			return errors.New("wrong live-session operation")
+		}
+		ack, err := peer.encrypt([]byte{7, 83, 0})
+		if err != nil {
+			return err
+		}
+		f.emit(2, ack)
+		status, err := peer.encrypt([]byte{8, 81, 0, 0, 0, 128, 46, 0, 16})
+		if err != nil {
+			return err
+		}
+		f.emit(2, status)
+		return nil
+	}
+	p := PrepareRegistration(context.Background(), testTarget, func(context.Context, Target) (Link, Target, error) { dials.Add(1); return f, testTarget, nil })
+	defer p.Close()
+	waitRegistration(t, p, "ready")
+	if registrations.Load() != 0 || linkClosed(f) {
+		t.Fatal("acted before Register")
+	}
+	saved := ""
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	c, err := p.Register(ctx, func(key Credential) error { saved = key.Secret; return nil })
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer c.Close()
+	if saved == "" || saved != expectedSecret {
+		t.Fatal("credential not persisted")
+	}
+	p.Close()
+	if linkClosed(f) {
+		t.Fatal("handoff closed the live connection")
+	}
+	if state := c.State(); !state.Online || state.Position == nil || *state.Position != 45 || state.LockPosition == nil || *state.LockPosition != 90 {
+		t.Fatalf("registration state lost: %+v", state)
+	}
+	life, stop := context.WithCancel(context.Background())
+	done := make(chan struct{})
+	go func() { c.Run(life); close(done) }()
+	defer func() { stop(); <-done }()
+	if err = c.Action(ctx, "unlock"); err != nil {
+		t.Fatal(err)
+	}
+	deadline := time.Now().Add(time.Second)
+	for c.State().Position != nil && *c.State().Position != 46 && time.Now().Before(deadline) {
+		time.Sleep(time.Millisecond)
+	}
+	if c.State().Position == nil || *c.State().Position != 46 {
+		t.Fatal("live notifications not handed off")
+	}
+	if dials.Load() != 1 || registrations.Load() != 1 || logins.Load() != 0 {
+		t.Fatal("unexpected reconnect, replay or login")
+	}
+}
+
+func TestPreparedRegistrationCannotRegisterBeforeInitialAndClosesOnCancel(t *testing.T) {
+	f := newLink()
+	<-f.packets
+	var writes atomic.Int32
+	f.write = func(byte, []byte) error { writes.Add(1); return nil }
+	p := PrepareRegistration(context.Background(), testTarget, func(context.Context, Target) (Link, Target, error) { return f, testTarget, nil })
+	waitRegistration(t, p, "waiting-token")
+	if _, err := p.Register(context.Background(), func(Credential) error { return nil }); err == nil {
+		t.Fatal("registered before initial token")
+	}
+	p.Close()
+	if !linkClosed(f) || writes.Load() != 0 {
+		t.Fatal("cancel did not clean up idle link")
+	}
+}
+
+func TestPreparedRegistrationDisconnectAndTimeoutNeverReplay(t *testing.T) {
+	for _, lost := range []bool{true, false} {
+		t.Run(map[bool]string{true: "disconnect", false: "exchange-timeout"}[lost], func(t *testing.T) {
+			f := newLink()
+			var writes atomic.Int32
+			f.write = func(byte, []byte) error { writes.Add(1); return nil }
+			p := PrepareRegistration(context.Background(), testTarget, func(context.Context, Target) (Link, Target, error) { return f, testTarget, nil })
+			defer p.Close()
+			waitRegistration(t, p, "ready")
+			if lost {
+				f.done <- errors.New("lost link")
+				waitRegistration(t, p, "disconnected")
+			}
+			ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+			defer cancel()
+			_, err := p.Register(ctx, func(Credential) error { t.Fatal("persisted without response"); return nil })
+			if err == nil {
+				t.Fatal("accepted incomplete registration")
+			}
+			<-p.done
+			want := int32(1)
+			if lost {
+				want = 0
+			}
+			if writes.Load() != want || !linkClosed(f) {
+				t.Fatal("replayed command or leaked link")
+			}
+			if _, err = p.Register(context.Background(), func(Credential) error { return nil }); err == nil {
+				t.Fatal("reused failed registration")
+			}
+			if writes.Load() != want {
+				t.Fatal("replayed failed registration")
+			}
+		})
+	}
+}
+
+func TestPreparedRegistrationCancelsInFlightSetup(t *testing.T) {
+	started := make(chan struct{})
+	p := PrepareRegistration(context.Background(), testTarget, func(ctx context.Context, _ Target) (Link, Target, error) {
+		close(started)
+		<-ctx.Done()
+		return nil, Target{}, ctx.Err()
+	})
+	<-started
+	p.Close()
+	if p.State().Connected {
+		t.Fatal("connected after setup cancellation")
+	}
+}
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index 1590d7e..febdd57 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.0.16
+
+- Follow official android app's workflow
+
 ## 0.0.15
 
 - Extend GATT timeout
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index 4037d06..1c81fa5 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -39,13 +39,18 @@ are rejected rather than overwriting a newer configuration.
 
 ## Physical locks (Sesame)
 
-1. In **Physical locks**, click **Add physical lock**. Scan results show supported
-   nearby Sesame locks and their local Bluetooth adapters.
-2. For an unregistered lock, choose **Register an unregistered lock**. This
-   provisions BLE access and saves its key immediately. It does not reset an
-   existing lock or enroll the lock in a Sesame cloud account.
-3. For a lock already configured in the Sesame app, choose **Import an existing
-   key** and paste the SDK key JSON containing `deviceUUID`, `deviceModel`, and
+1. Open the Web UI or click **Scan for locks**. Unregistered Sesame locks connect
+   immediately during discovery. **Bluetooth connections** lists all connected
+   Sesame devices, including locks waiting for registration, with their adapters.
+2. Wait for **Ready to register**, click that lock's **Register** button, enter a
+   name, and click **Register** in the dialog. Nothing is registered before this
+   confirmation. Registration saves the key and keeps the same connection for
+   operation; no disconnect or second login is needed. It does not reset an
+   existing lock or enroll it in a Sesame cloud account. **Disconnect** releases
+   an idle registration connection. To retry an ended connection, scan again.
+3. For a lock already configured in the Sesame app, click **Add physical lock**,
+   select the registered lock, and paste the SDK key JSON containing
+   `deviceUUID`, `deviceModel`, and
    `secretKey` (the SDK also exports `keyIndex` and `sesame2PublicKey`). The key must
    match the selected device and pass a local login. Cloud-only guest keys do
    not work. This is not a parser for arbitrary Sesame sharing QR codes.
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index e27a8b8..5238efa 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.15"
+version: "0.0.16"
 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/page.html b/internal/app/page.html
index 3ad77f0..2afe23d 100644
--- a/internal/app/page.html
+++ b/internal/app/page.html
@@ -8,13 +8,13 @@
 <div class="toolbar"><button id="add-lock" class="primary">Add lock</button><button id="save" disabled>Save configuration</button></div>
 <p id="message" class="notice" role="status"></p><main id="locks"></main>
 <section><h2>NFC readers</h2><p class="muted">Readers can be assigned to several locks in the same Apple Home.</p><div id="readers"></div><div class="toolbar"><button id="add-reader">Add reader</button><button id="scan">Scan Bluetooth</button></div><p id="discovery-status" class="muted" role="status"></p></section>
-<section><h2>Physical locks</h2><p class="muted">Add a Sesame lock, set its positions, then assign it to a Home Key lock.</p><div id="physical-locks"></div><div class="toolbar"><button id="add-physical">Add physical lock</button><button id="scan-physical">Scan for locks</button></div><p id="physical-discovery-status" class="muted" role="status"></p></section>
-<section><h2>Bluetooth connections</h2><p class="muted">Connected Sesame devices, including locks not yet added above. Disconnect a stuck connection before retrying registration. Configured locks may reconnect automatically.</p><button id="refresh-connections" type="button">Refresh connections</button><p id="connections-status" class="muted" role="status"></p><div id="sesame-connections"></div></section>
+<section><h2>Physical locks</h2><p class="muted">Scanning connects unregistered Sesame locks immediately. Wait for Ready to register below, then click Register. Set positions after registration and assign the lock to a Home Key lock.</p><div id="physical-locks"></div><div class="toolbar"><button id="add-physical">Add physical lock</button><button id="scan-physical">Scan for locks</button></div><p id="physical-discovery-status" class="muted" role="status"></p></section>
+<section><h2>Bluetooth connections</h2><p class="muted">All connected Sesame devices and locks preparing for registration. Register claims only the lock you select. Disconnect releases a waiting connection; configured locks may reconnect automatically.</p><button id="refresh-connections" type="button">Refresh connections</button><p id="connections-status" class="muted" role="status"></p><div id="sesame-connections"></div></section>
 <details><summary>Network settings</summary><label>LAN interface <input id="interface" placeholder="Automatic"></label><small>Leave empty for automatic HomeKit discovery interface selection.</small></details>
 <p class="muted">Saving briefly restarts HomeKit and reader connections. Pairings and credentials are preserved.</p>
 <dialog id="lock-dialog"><form id="lock-form"><h2 id="lock-title">Add lock</h2><small id="lock-id"></small><label>Name <input id="lock-name" required maxlength="128"></label><fieldset><legend>Assigned readers</legend><div id="assignments"></div></fieldset><fieldset><legend>Assigned physical locks</legend><div id="physical-assignments"></div><small>With none assigned, this remains a simulated lock.</small></fieldset><details><summary>HomeKit settings</summary><label>HomeKit port <input id="lock-port" type="number" min="1" max="65535" required></label><label>Wallet artwork <select id="lock-finish"><option>silver</option><option>black</option><option>gold</option><option>tan</option></select></label></details><p id="lock-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-lock">Cancel</button></div></form></dialog>
 <dialog id="reader-dialog"><form id="reader-form"><h2 id="reader-title">Add reader</h2><label>Available reader <select id="reader-device"></select></label><label id="manual-address-label" hidden>Bluetooth address <input id="reader-address" placeholder="70:AF:09:16:42:5A"></label><label>Bluetooth adapter <select id="reader-adapter"></select></label><label>Reader name <input id="reader-name" required maxlength="128"></label><small id="reader-id"></small><p id="reader-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-reader">Cancel</button></div></form></dialog>
-<dialog id="physical-dialog"><form id="physical-form"><h2>Add physical lock</h2><p>Select a nearby lock. For a lock already set up in Sesame, import its local key JSON.</p><label>Available lock <select id="physical-device" required></select></label><label>Name <input id="physical-name" required maxlength="128"></label><label>Setup method <select id="physical-mode"><option value="import">Import an existing key</option><option value="register">Register an unregistered lock</option></select></label><label id="physical-key-label">Sesame key JSON <textarea id="physical-key" rows="6" spellcheck="false" autocomplete="off" style="box-sizing:border-box;width:100%;display:block"></textarea></label><p id="physical-register-note" hidden>Registration claims this unregistered lock and saves its key here. This does not reset an existing lock or add it to the Sesame cloud account.</p><p id="physical-error" class="error" role="alert"></p><div class="toolbar"><button id="physical-submit" class="primary" type="submit">Add physical lock</button><button type="button" id="cancel-physical">Cancel</button></div></form></dialog>
+<dialog id="physical-dialog"><form id="physical-form"><h2>Add physical lock</h2><p>Select a lock. Unregistered locks must already show Ready to register. For a lock already set up in Sesame, import its local key JSON.</p><label>Available lock <select id="physical-device" required></select></label><label>Name <input id="physical-name" required maxlength="128"></label><label>Setup method <select id="physical-mode"><option value="import">Import an existing key</option><option value="register">Register an unregistered lock</option></select></label><label id="physical-key-label">Sesame key JSON <textarea id="physical-key" rows="6" spellcheck="false" autocomplete="off" style="box-sizing:border-box;width:100%;display:block"></textarea></label><p id="physical-register-note" hidden>Registration uses the waiting connection and saves its key here. This does not reset an existing lock or add it to the Sesame cloud account.</p><p id="physical-error" class="error" role="alert"></p><div class="toolbar"><button id="physical-submit" class="primary" type="submit">Add physical lock</button><button type="button" id="cancel-physical">Cancel</button></div></form></dialog>
 <dialog id="calibration-dialog"><h2 id="calibration-name">Lock positions</h2><p>Turn the thumb-turn manually to each desired position, wait for the displayed position to update, then save it. Test movement with the door open.</p><p id="calibration-status" role="status"></p><dl><dt>Current position</dt><dd id="calibration-position">Waiting…</dd><dt>Locked position</dt><dd id="calibration-locked">Waiting…</dd><dt>Unlocked position</dt><dd id="calibration-unlocked">Waiting…</dd><dt>Lock/unlock boundary</dt><dd id="calibration-boundary">Waiting…</dd></dl><div class="toolbar"><button id="capture-lock">Use current as locked</button><button id="capture-unlock">Use current as unlocked</button><button id="capture-boundary">Use current as boundary</button></div><small id="boundary-help"></small><div class="toolbar"><button id="test-lock">Test lock</button><button id="test-unlock">Test unlock</button></div><p id="calibration-error" class="error" role="alert"></p><button id="close-calibration">Close</button></dialog>
 <script>
 const $=id=>document.getElementById(id);
@@ -22,6 +22,7 @@ const apiBase=location.pathname+(location.pathname.endsWith('/')?'':'/');
 const el=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};
 const button=(text,fn)=>{const b=el('button',text);b.type='button';b.onclick=fn;return b};
 let config={locks:[],readers:[],interface:''},revision='',status=[],discovery={adapters:[],readers:[]},dirty=false,restarting=false,scanning=false,editingLock=null,editingReader=null;
+let registrationConnections=[],bluetoothConnections=[];
 let physicalLocks=[],physicalDiscovery={locks:[],adapters:[],warnings:[]},physicalScanning=false,physicalAdding=false,calibrating=null,calibrationBusy=false,physicalRemoving=false;
 const label=r=>r.name||r.id;
 function message(text,error=false){$('message').textContent=text;$('message').className='notice'+(error?' error':'')}
@@ -64,34 +65,45 @@ async function removePhysical(p){
  catch(e){message(e.message,true)}finally{physicalRemoving=false;renderPhysical()}
 }
 function physicalState(s){if(!s.online)return s.error||'Connecting…';if(s.position===undefined)return 'Connected; waiting for position';if(s.critical)return 'Lock reports a critical condition';if(!s.stopped)return 'Moving · '+s.position+'°';return (s.locked?'Locked':'Unlocked')+' · '+s.position+'°'+(s.battery_critical?' · Low battery':'')}
-function physicalChoices(){const old=$('physical-device').value,available=physicalDiscovery.locks.filter(t=>!physicalLocks.some(p=>p.target.uuid===t.uuid));options($('physical-device'),available.map(t=>({value:t.adapter+'|'+t.uuid,label:t.model+' · '+t.address+' · '+t.adapter+(t.registered?' · Registered':' · Unregistered')})),old);selectPhysical()}
-function selectedPhysical(){return physicalDiscovery.locks.find(t=>t.adapter+'|'+t.uuid===$('physical-device').value)}
-function selectPhysical(){const t=selectedPhysical();if(t){$('physical-mode').value=t.registered?'import':'register';if(!$('physical-name').value)$('physical-name').value=t.model.replaceAll('_',' ')}physicalMode();$('physical-submit').disabled=!t||physicalAdding}
-function physicalMode(){const registering=$('physical-mode').value==='register';$('physical-key-label').hidden=registering;$('physical-key').required=!registering;$('physical-register-note').hidden=!registering}
-async function scanPhysical(){if(physicalScanning)return;physicalScanning=true;$('scan-physical').disabled=true;$('physical-discovery-status').textContent='Scanning for supported Sesame locks…';try{physicalDiscovery=await request('api/physical-locks/discovery');$('physical-discovery-status').textContent=physicalDiscovery.locks.length+' supported lock(s) found. '+(physicalDiscovery.warnings||[]).join(' · ');if($('physical-dialog').open)physicalChoices()}catch(e){$('physical-discovery-status').textContent=e.message;if($('physical-dialog').open)$('physical-error').textContent=e.message}finally{physicalScanning=false;$('scan-physical').disabled=false}}
-let connectionsBusy=false;
+function physicalTargets(){const targets=new Map(physicalDiscovery.locks.map(t=>[t.adapter+'|'+t.uuid,t]));for(const p of registrationConnections)targets.set(p.target.adapter+'|'+p.target.uuid,p.target);return [...targets.values()]}
+function registrationFor(t){return registrationConnections.find(p=>p.target.adapter===t?.adapter&&p.target.uuid===t?.uuid)}
+function registrationLabel(p){return ({connecting:'Connecting…','waiting-token':'Connected; waiting for initial token…',ready:'Ready to register',registering:'Registering…',registered:'Registered',failed:'Connection failed',disconnected:'Disconnected'})[p?.phase]||'Scan to connect'}
+function physicalChoices(){const old=$('physical-device').value,available=physicalTargets().filter(t=>!physicalLocks.some(p=>p.target.uuid===t.uuid));options($('physical-device'),available.map(t=>({value:t.adapter+'|'+t.uuid,label:t.model+' · '+t.address+' · '+t.adapter+(t.registered?' · Registered':' · '+registrationLabel(registrationFor(t)))})),old);selectPhysical()}
+function selectedPhysical(){return physicalTargets().find(t=>t.adapter+'|'+t.uuid===$('physical-device').value)}
+function selectPhysical(){const t=selectedPhysical();if(t){$('physical-mode').value=t.registered?'import':'register';if(!$('physical-name').value)$('physical-name').value=t.model.replaceAll('_',' ')}physicalMode()}
+function physicalMode(){const t=selectedPhysical(),registering=$('physical-mode').value==='register',p=registrationFor(t);$('physical-mode').disabled=true;$('physical-key-label').hidden=registering;$('physical-key').required=!registering;$('physical-register-note').hidden=!registering;$('physical-submit').textContent=registering?'Register':'Import key';$('physical-submit').disabled=physicalAdding||!t||(registering&&p?.phase!=='ready');if(!physicalAdding&&registering)$('physical-register-note').textContent=registrationLabel(p)+'. Registration uses this waiting connection and saves its key here. It does not add the lock to the Sesame cloud account.'}
+async function scanPhysical(){if(physicalScanning||physicalAdding)return;physicalScanning=true;$('scan-physical').disabled=true;$('physical-discovery-status').textContent='Scanning and connecting unregistered Sesame locks…';try{physicalDiscovery=await request('api/physical-locks/scan',{method:'POST',headers:{'X-Homekey-Action':'physical-lock'}});$('physical-discovery-status').textContent=physicalDiscovery.locks.length+' supported lock(s) found. Unregistered locks are connecting; check Bluetooth connections below. '+(physicalDiscovery.warnings||[]).join(' · ');if($('physical-dialog').open)physicalChoices();await refreshConnections()}catch(e){$('physical-discovery-status').textContent=e.message;if($('physical-dialog').open)$('physical-error').textContent=e.message}finally{physicalScanning=false;$('scan-physical').disabled=false}}
+let connectionsBusy=false,disconnecting=false;
+function renderConnections(){
+ const list=$('sesame-connections');list.replaceChildren();
+ const rows=bluetoothConnections.map(c=>({...c,preparation:registrationConnections.find(p=>p.target.adapter===c.adapter&&p.target.address.toUpperCase()===c.address.toUpperCase())}));
+ for(const p of registrationConnections){if(!rows.some(c=>c.adapter===p.target.adapter&&c.address.toUpperCase()===p.target.address.toUpperCase()))rows.push({adapter:p.target.adapter,address:p.target.address,name:p.target.model,preparation:p})}
+ for(const c of rows){const p=c.preparation,row=el('div','','reader'),info=el('div',''),actions=el('div','','toolbar');info.append(el('strong',c.name||c.address),el('p',c.address+' · '+c.adapter+' · '+(p?registrationLabel(p):'Connected'),'muted'));if(p?.error)info.append(el('p',p.error,'error'));
+ if(p&&!physicalLocks.some(lock=>lock.target.uuid===p.target.uuid)){const register=button('Register',()=>openPhysical(p.target));register.disabled=p.phase!=='ready'||physicalAdding||restarting;actions.append(register)}
+ const disconnect=button('Disconnect',async()=>{
+ if(disconnecting||physicalAdding)return;let disconnectError='';disconnecting=true;renderConnections();$('connections-status').textContent='Disconnecting '+c.address+'…';
+ try{await request('api/bluetooth/sesame-connections/'+encodeURIComponent(c.adapter)+'/'+encodeURIComponent(c.address)+'/disconnect',{method:'POST',headers:{'X-Homekey-Action':'physical-lock'}});await refreshConnections()}
+ catch(e){disconnectError=e.message}finally{disconnecting=false;renderConnections();if(disconnectError)$('connections-status').textContent=disconnectError}
+ });disconnect.disabled=disconnecting||physicalAdding||p?.phase==='registering';actions.append(disconnect);row.append(info,actions);list.append(row)}
+ $('connections-status').textContent=bluetoothConnections.length+' connected Sesame device(s)'+(registrationConnections.length?' · '+registrationConnections.filter(p=>p.phase==='ready').length+' ready to register.':'.');
+}
 async function refreshConnections(){
  if(connectionsBusy)return;
- connectionsBusy=true;$('refresh-connections').disabled=true;$('connections-status').textContent='Reading Bluetooth connections…';
- try{const connections=await request('api/bluetooth/sesame-connections');const list=$('sesame-connections');list.replaceChildren();
- for(const c of connections){const row=el('div','','reader'),info=el('div','');info.append(el('strong',c.name||c.address),el('p',c.address+' · '+c.adapter+' · Connected','muted'));const disconnect=button('Disconnect',async()=>{
- disconnect.disabled=true;$('connections-status').textContent='Disconnecting '+c.address+'…';
- try{await request('api/bluetooth/sesame-connections/'+encodeURIComponent(c.adapter)+'/'+encodeURIComponent(c.address)+'/disconnect',{method:'POST',headers:{'X-Homekey-Action':'physical-lock'}});await refreshConnections()}
- catch(e){$('connections-status').textContent=e.message;disconnect.disabled=false}
- });row.append(info,disconnect);list.append(row)}
- $('connections-status').textContent=connections.length?connections.length+' connected Sesame device(s).':'No connected Sesame devices.';
- }catch(e){$('sesame-connections').replaceChildren();$('connections-status').textContent=e.message}
+ connectionsBusy=true;$('refresh-connections').disabled=true;
+ try{const [connections,preparations]=await Promise.all([request('api/bluetooth/sesame-connections'),request('api/physical-locks/registration-connections')]);bluetoothConnections=connections;registrationConnections=preparations;renderConnections();if($('physical-dialog').open&&!physicalAdding)physicalChoices()}
+ catch(e){$('connections-status').textContent=e.message}
  finally{connectionsBusy=false;$('refresh-connections').disabled=false}
 }
+function openPhysical(target=null){if(physicalAdding)return;$('physical-name').value='';$('physical-key').value='';$('physical-error').textContent='';physicalChoices();if(target){$('physical-device').value=target.adapter+'|'+target.uuid;selectPhysical()}$('physical-dialog').showModal();if(!target)scanPhysical()}
 $('refresh-connections').onclick=refreshConnections;
 refreshConnections();
 $('scan-physical').onclick=scanPhysical;
-$('add-physical').onclick=()=>{$('physical-name').value='';$('physical-key').value='';$('physical-error').textContent='';physicalChoices();$('physical-dialog').showModal();scanPhysical()};
+$('add-physical').onclick=()=>openPhysical();
 $('physical-device').onchange=selectPhysical;$('physical-mode').onchange=physicalMode;
 $('cancel-physical').onclick=()=>{if(!physicalAdding){$('physical-key').value='';$('physical-dialog').close()}};
 $('physical-dialog').addEventListener('cancel',e=>{if(physicalAdding)e.preventDefault();else $('physical-key').value=''});
-$('physical-form').onsubmit=async e=>{e.preventDefault();if(physicalAdding)return;const target=selectedPhysical();if(!target){$('physical-error').textContent='Scan and select a supported lock first.';return}const mode=$('physical-mode').value,body={name:$('physical-name').value.trim(),target,mode};try{if(mode==='import'){const k=JSON.parse($('physical-key').value);body.key={deviceUUID:k.deviceUUID,deviceModel:k.deviceModel,secretKey:k.secretKey,keyIndex:k.keyIndex||'',sesame2PublicKey:k.sesame2PublicKey||''}}}catch{$('physical-error').textContent='Enter valid Sesame key JSON.';return}
- physicalAdding=true;for(const n of $('physical-form').elements)n.disabled=true;$('physical-error').textContent=mode==='register'?'Registering and saving the key…':'Verifying the key…';try{const added=await request('api/physical-locks',{method:'POST',headers:{'Content-Type':'application/json','X-Homekey-Action':'physical-lock'},body:JSON.stringify(body)});$('physical-key').value='';$('physical-dialog').close();await refresh();openCalibration(added.id)}catch(err){$('physical-error').textContent=err.message}finally{physicalAdding=false;for(const n of $('physical-form').elements)n.disabled=false}}
+$('physical-form').onsubmit=async e=>{e.preventDefault();if(physicalAdding)return;const target=selectedPhysical();if(!target){$('physical-error').textContent='Scan and select a supported lock first.';return}const mode=$('physical-mode').value;if(mode==='register'&&registrationFor(target)?.phase!=='ready'){$('physical-error').textContent='Wait for Ready to register; scan again if the connection ended.';return}const body={name:$('physical-name').value.trim(),target,mode};try{if(mode==='import'){const k=JSON.parse($('physical-key').value);body.key={deviceUUID:k.deviceUUID,deviceModel:k.deviceModel,secretKey:k.secretKey,keyIndex:k.keyIndex||'',sesame2PublicKey:k.sesame2PublicKey||''}}}catch{$('physical-error').textContent='Enter valid Sesame key JSON.';return}
+ physicalAdding=true;renderConnections();for(const n of $('physical-form').elements)n.disabled=true;$('physical-error').textContent=mode==='register'?'Registering and saving the key…':'Verifying the key…';try{const added=await request('api/physical-locks',{method:'POST',headers:{'Content-Type':'application/json','X-Homekey-Action':'physical-lock'},body:JSON.stringify(body)});$('physical-key').value='';$('physical-dialog').close();await refresh();await refreshConnections();openCalibration(added.id)}catch(err){$('physical-error').textContent=err.message}finally{physicalAdding=false;for(const n of $('physical-form').elements)n.disabled=false;physicalMode();renderConnections()}}
 function openCalibration(id){calibrating=id;$('calibration-error').textContent='';updateCalibration();$('calibration-dialog').showModal()}
 function updateCalibration(){if(!calibrating)return;const p=physicalLocks.find(p=>p.id===calibrating);if(!p)return;const s=p.state,angle=v=>v===undefined?'Not reported':v+'°';$('calibration-name').textContent=p.name+' — positions';$('calibration-status').textContent=physicalState(s);$('calibration-position').textContent=angle(s.position);$('calibration-locked').textContent=angle(s.lock_position);$('calibration-unlocked').textContent=angle(s.unlock_position);$('calibration-boundary').textContent=angle(s.boundary);
  const ready=s.online&&s.position!==undefined&&!calibrationBusy;
@@ -100,5 +112,5 @@ function updateCalibration(){if(!calibrating)return;const p=physicalLocks.find(p
 }
 async function physicalAction(action){if(calibrationBusy||!calibrating)return;calibrationBusy=true;$('calibration-error').textContent='';updateCalibration();try{await request('api/physical-locks/'+encodeURIComponent(calibrating)+'/'+action,{method:'POST',headers:{'X-Homekey-Action':'physical-lock'}});await refresh()}catch(e){$('calibration-error').textContent=e.message}finally{calibrationBusy=false;updateCalibration()}}
 $('capture-lock').onclick=()=>physicalAction('set-lock');$('capture-unlock').onclick=()=>physicalAction('set-unlock');$('capture-boundary').onclick=()=>physicalAction('set-boundary');$('test-lock').onclick=()=>physicalAction('lock');$('test-unlock').onclick=()=>physicalAction('unlock');$('close-calibration').onclick=()=>{$('calibration-dialog').close();calibrating=null};
-load().then(()=>scan()).catch(e=>message(e.message,true));setInterval(()=>{if(!restarting)refresh()},2000);
+load().then(()=>Promise.all([scan(),scanPhysical()])).catch(e=>message(e.message,true));setInterval(()=>{if(!restarting){refresh();refreshConnections()}},2000);
 </script></body></html>
diff --git a/internal/app/physical.go b/internal/app/physical.go
index 461e649..c9d08ce 100644
--- a/internal/app/physical.go
+++ b/internal/app/physical.go
@@ -99,6 +99,10 @@ 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 {
 			http.Error(w, err.Error(), 409)
 			return
@@ -109,6 +113,26 @@ func physicalRoutes(mux *http.ServeMux, cfg *Settings) {
 		w.Header().Set("Content-Type", "application/json")
 		json.NewEncoder(w).Encode(cfg.Physical.Views())
 	})
+	mux.HandleFunc("GET /api/physical-locks/registration-connections", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(cfg.Physical.RegistrationConnections())
+	})
+	// Scanning now creates retained connections, so use an explicit action POST.
+	mux.HandleFunc("POST /api/physical-locks/scan", func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("X-Homekey-Action") != "physical-lock" {
+			http.Error(w, "missing action header", 403)
+			return
+		}
+		ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
+		defer cancel()
+		d, e := sesame.DiscoverWithCallback(ctx, cfg.Physical.Prepare)
+		if e != nil {
+			http.Error(w, e.Error(), 503)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(d)
+	})
 	mux.HandleFunc("GET /api/physical-locks/discovery", func(w http.ResponseWriter, r *http.Request) {
 		ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
 		defer cancel()
diff --git a/internal/app/physical_test.go b/internal/app/physical_test.go
index f79051d..a03f512 100644
--- a/internal/app/physical_test.go
+++ b/internal/app/physical_test.go
@@ -33,7 +33,7 @@ func TestPhysicalHTTPGuardsAndEmptyRegistry(t *testing.T) {
 	for _, tc := range []struct {
 		method, path, body, header string
 		want                       int
-	}{{"GET", "/api/physical-locks", "", "", 200}, {"POST", "/api/physical-locks", "{}", "", 403}, {"POST", "/api/physical-locks", "{} {}", "physical-lock", 400}, {"POST", "/api/physical-locks", "{\"secret\":\"bad\"}", "physical-lock", 400}, {"POST", "/api/physical-locks/unknown/unlock", "", "", 403}, {"POST", "/api/physical-locks/unknown/unlock", "", "physical-lock", 404}, {"POST", "/api/physical-locks/unknown/reset", "", "physical-lock", 404}} {
+	}{{"GET", "/api/physical-locks", "", "", 200}, {"GET", "/api/physical-locks/registration-connections", "", "", 200}, {"POST", "/api/physical-locks/scan", "", "", 403}, {"POST", "/api/physical-locks", "{}", "", 403}, {"POST", "/api/physical-locks", "{} {}", "physical-lock", 400}, {"POST", "/api/physical-locks", "{\"secret\":\"bad\"}", "physical-lock", 400}, {"POST", "/api/physical-locks/unknown/unlock", "", "", 403}, {"POST", "/api/physical-locks/unknown/unlock", "", "physical-lock", 404}, {"POST", "/api/physical-locks/unknown/reset", "", "physical-lock", 404}} {
 		r := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
 		r.Header.Set("X-Homekey-Action", tc.header)
 		w := httptest.NewRecorder()
diff --git a/internal/physical/manager.go b/internal/physical/manager.go
index 17c4566..b442072 100644
--- a/internal/physical/manager.go
+++ b/internal/physical/manager.go
@@ -51,6 +51,7 @@ type Manager struct {
 	clients      map[string]*sesame.Client
 	clientCancel map[string]context.CancelFunc
 	clientDone   map[string]<-chan struct{}
+	pending      map[string]*sesame.RegistrationConnection
 	wg           sync.WaitGroup
 }
 
@@ -59,7 +60,7 @@ func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error)
 		dial = sesame.DialBlueZ
 	}
 	life, cancel := context.WithCancel(ctx)
-	m := &Manager{ctx: life, cancel: cancel, dir: dir, dial: dial, records: map[string]Record{}, clients: map[string]*sesame.Client{}, clientCancel: map[string]context.CancelFunc{}, clientDone: map[string]<-chan struct{}{}}
+	m := &Manager{ctx: life, cancel: cancel, dir: dir, dial: dial, records: map[string]Record{}, clients: map[string]*sesame.Client{}, clientCancel: map[string]context.CancelFunc{}, clientDone: map[string]<-chan struct{}{}, pending: map[string]*sesame.RegistrationConnection{}}
 	if e := os.MkdirAll(filepath.Join(dir, "physical-lock-keys"), 0700); e != nil {
 		cancel()
 		return nil, e
@@ -186,7 +187,9 @@ func writeJSON(path string, v any) error {
 	return d.Sync()
 }
 func (m *Manager) start(r Record, key sesame.Credential) {
-	c := sesame.NewClient(r.Target, key, m.dial)
+	m.startClient(r, sesame.NewClient(r.Target, key, m.dial))
+}
+func (m *Manager) startClient(r Record, c *sesame.Client) {
 	ctx, cancel := context.WithCancel(m.ctx)
 	done := make(chan struct{})
 	m.clients[r.ID] = c
@@ -248,7 +251,15 @@ func (m *Manager) Remove(id string) error {
 	}
 	return nil
 }
-func (m *Manager) Close() { m.cancel(); m.enroll.Lock(); defer m.enroll.Unlock(); m.wg.Wait() }
+func (m *Manager) Close() {
+	m.cancel()
+	m.enroll.Lock()
+	defer m.enroll.Unlock()
+	for _, p := range m.pending {
+		p.Close()
+	}
+	m.wg.Wait()
+}
 func (m *Manager) Has(id string) bool {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
@@ -301,6 +312,12 @@ func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) {
 	}
 	probe.Close()
 	os.Remove(probe.Name())
+	var prepared *sesame.Client
+	defer func() {
+		if prepared != nil {
+			prepared.Close()
+		}
+	}()
 	var key sesame.Credential
 	if recovered, err := m.readKey(id); err == nil {
 		// Retry can recover a key persisted before a failed registry write.
@@ -325,7 +342,19 @@ func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) {
 			e = writeJSON(m.keyPath(id), key)
 		}
 	} else {
-		e = sesame.Register(ctx, in.Target, m.dial, func(c sesame.Credential) error { key = c; return writeJSON(m.keyPath(id), c) })
+		m.mu.RLock()
+		connection := m.pending[id]
+		m.mu.RUnlock()
+		if connection == nil || connection.State().Target.Adapter != in.Target.Adapter {
+			return Record{}, errors.New("scan first, then wait for Ready to register on the connected lock")
+		}
+		prepared, e = connection.Register(ctx, func(c sesame.Credential) error { key = c; return writeJSON(m.keyPath(id), c) })
+		if e == nil {
+			in.Target = connection.State().Target
+			m.mu.Lock()
+			delete(m.pending, id) // Ownership transferred to prepared, even if registry write fails.
+			m.mu.Unlock()
+		}
 	}
 	if e != nil {
 		return Record{}, e
@@ -343,7 +372,13 @@ func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) {
 		return Record{}, fmt.Errorf("key saved, but physical lock list could not be saved; retry adding this device: %w", e)
 	}
 	m.records[id] = r
-	m.start(r, key)
+	if prepared != nil {
+		m.startClient(r, prepared)
+		prepared = nil // The running client now owns the live registration session.
+	} else {
+		m.start(r, key)
+	}
+	delete(m.pending, id)
 	return r, nil
 }
 func (m *Manager) Action(ctx context.Context, id, name string) error {
diff --git a/internal/physical/registration.go b/internal/physical/registration.go
new file mode 100644
index 0000000..1cc474f
--- /dev/null
+++ b/internal/physical/registration.go
@@ -0,0 +1,80 @@
+package physical
+
+import (
+	"sort"
+	"strings"
+
+	"homekey.local/provisioner/drivers/sesame"
+)
+
+// Prepare connects unregistered scan results without claiming a lock. Repeated
+// scans reuse our live preparations; a device seen on two adapters is dialed once.
+// The manager lifetime, rather than the HTTP scan request, owns these links.
+func (m *Manager) Prepare(targets []sesame.Target) {
+	m.enroll.Lock()
+	defer m.enroll.Unlock()
+	if m.ctx.Err() != nil {
+		return
+	}
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, target := range targets {
+		if target.Registered {
+			continue
+		}
+		uuid, err := sesame.NormalizeUUID(target.UUID)
+		if err != nil {
+			continue
+		}
+		target.UUID = uuid
+		id, err := identity(target)
+		if err != nil || m.records[id].ID != "" {
+			continue
+		}
+		if old := m.pending[id]; old != nil {
+			switch old.State().Phase {
+			case "failed", "disconnected":
+				old.Close()
+			default:
+				continue
+			}
+		}
+		m.pending[id] = sesame.PrepareRegistration(m.ctx, target, m.dial)
+	}
+}
+
+func (m *Manager) RegistrationConnections() []sesame.RegistrationState {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	out := []sesame.RegistrationState{}
+	for _, p := range m.pending {
+		out = append(out, p.State())
+	}
+	sort.Slice(out, func(i, j int) bool {
+		return out[i].Target.Adapter+out[i].Target.UUID < out[j].Target.Adapter+out[j].Target.UUID
+	})
+	return out
+}
+
+// DisconnectRegistration releases a preparation owned by us, including a setup
+// still in progress. Unknown external links remain the BlueZ route's concern.
+func (m *Manager) DisconnectRegistration(adapter, address string) bool {
+	m.enroll.Lock()
+	defer m.enroll.Unlock()
+	m.mu.Lock()
+	var selected *sesame.RegistrationConnection
+	for id, p := range m.pending {
+		t := p.State().Target
+		if t.Adapter == adapter && strings.EqualFold(t.Address, address) {
+			selected = p
+			delete(m.pending, id)
+			break
+		}
+	}
+	m.mu.Unlock()
+	if selected == nil {
+		return false
+	}
+	selected.Close()
+	return true
+}
diff --git a/internal/physical/registration_test.go b/internal/physical/registration_test.go
new file mode 100644
index 0000000..1a84f98
--- /dev/null
+++ b/internal/physical/registration_test.go
@@ -0,0 +1,193 @@
+package physical
+
+import (
+	"context"
+	"crypto/ecdh"
+	"crypto/rand"
+	"encoding/json"
+	"errors"
+	"os"
+	"path/filepath"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"homekey.local/provisioner/drivers/sesame"
+)
+
+type registrationLink struct {
+	packets  chan []byte
+	done     chan error
+	mu       sync.Mutex
+	received []byte
+	public   []byte
+	writes   atomic.Int32
+	closed   atomic.Bool
+}
+
+func newRegistrationLink(t *testing.T) *registrationLink {
+	t.Helper()
+	key, err := ecdh.P256().GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	l := &registrationLink{packets: make(chan []byte, 16), done: make(chan error, 1), public: key.PublicKey().Bytes()[1:]}
+	l.packets <- []byte{3, 8, 14, 0x11, 0x22, 0x33, 0x44}
+	return l
+}
+func (l *registrationLink) Packets() <-chan []byte { return l.packets }
+func (l *registrationLink) Done() <-chan error     { return l.done }
+func (l *registrationLink) Close()                 { l.closed.Store(true) }
+func (l *registrationLink) Write(ctx context.Context, fragment []byte) error {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if err := ctx.Err(); err != nil {
+		return err
+	}
+	if fragment[0]&1 != 0 {
+		l.received = nil
+	}
+	l.received = append(l.received, fragment[1:]...)
+	if fragment[0]>>1 == 0 {
+		return nil
+	}
+	l.writes.Add(1)
+	if l.received[0] != 1 {
+		return errors.New("unexpected login or operation")
+	}
+	reply := make([]byte, 80)
+	copy(reply, []byte{7, 1, 0, 0, 0, 0, 128, 45, 0, 18, 90, 0, 0, 0, 0, 0})
+	copy(reply[16:], l.public)
+	start := byte(1)
+	for len(reply) > 0 {
+		n := len(reply)
+		if n > 19 {
+			n = 19
+		}
+		header := start
+		if n == len(reply) {
+			header |= 2
+		}
+		l.packets <- append([]byte{header}, reply[:n]...)
+		reply = reply[n:]
+		start = 0
+	}
+	return nil
+}
+func awaitPreparation(t *testing.T, m *Manager, phase string) {
+	t.Helper()
+	deadline := time.Now().Add(time.Second)
+	for time.Now().Before(deadline) {
+		states := m.RegistrationConnections()
+		if len(states) == 1 && states[0].Phase == phase {
+			return
+		}
+		time.Sleep(time.Millisecond)
+	}
+	t.Fatalf("preparation = %+v, want %s", m.RegistrationConnections(), phase)
+}
+func pendingTarget() sesame.Target {
+	return sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Address: "AA:BB:CC:DD:EE:FF"}
+}
+
+func TestScanPreparationDeduplicatesAndRegistersExistingLink(t *testing.T) {
+	l := newRegistrationLink(t)
+	var dials atomic.Int32
+	dir := t.TempDir()
+	m, err := Open(context.Background(), dir, func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
+		dials.Add(1)
+		return l, target, nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer m.Close()
+	target := pendingTarget()
+	if _, err = m.Add(context.Background(), Enrollment{Name: "Door", Target: target, Mode: "register"}); err == nil {
+		t.Fatal("Add dialed without preparation")
+	}
+	m.Prepare([]sesame.Target{target})
+	awaitPreparation(t, m, "ready")
+	other := target
+	other.Adapter = "hci1"
+	m.Prepare([]sesame.Target{target, other})
+	awaitPreparation(t, m, "ready")
+	if dials.Load() != 1 || l.writes.Load() != 0 {
+		t.Fatal("duplicate dial or registration before click")
+	}
+	record, err := m.Add(context.Background(), Enrollment{Name: "Door", Target: target, Mode: "register"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !m.Has(record.ID) || !record.Target.Registered || len(m.RegistrationConnections()) != 0 {
+		t.Fatal("handoff not committed")
+	}
+	if dials.Load() != 1 || l.writes.Load() != 1 || l.closed.Load() {
+		t.Fatal("registration reconnected, logged in, or closed")
+	}
+	views := m.Views()
+	if len(views) != 1 || views[0].State.Position == nil || *views[0].State.Position != 45 {
+		t.Fatal("registration status missing")
+	}
+	key, err := m.readKey(record.ID)
+	if err != nil || len(key.Secret) != 32 {
+		t.Fatal("missing registration key", err)
+	}
+	raw, _ := json.Marshal(m.RegistrationConnections())
+	if string(raw) != "[]" {
+		t.Fatal("prepared list not empty")
+	}
+	m.Close()
+	if !l.closed.Load() {
+		t.Fatal("shutdown leaked registered connection")
+	}
+}
+
+func TestDisconnectPreparationAndShutdownCancelOwnedLinks(t *testing.T) {
+	l := newRegistrationLink(t)
+	m, err := Open(context.Background(), t.TempDir(), func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
+		return l, target, nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer m.Close()
+	target := pendingTarget()
+	m.Prepare([]sesame.Target{target})
+	awaitPreparation(t, m, "ready")
+	if !m.DisconnectRegistration(target.Adapter, target.Address) || !l.closed.Load() || len(m.RegistrationConnections()) != 0 {
+		t.Fatal("waiting link not released")
+	}
+	if m.DisconnectRegistration(target.Adapter, target.Address) {
+		t.Fatal("claimed external link")
+	}
+}
+
+func TestFailedRegistryWriteClosesHandoffAndRetainsRecoveryKey(t *testing.T) {
+	l := newRegistrationLink(t)
+	dir := t.TempDir()
+	m, err := Open(context.Background(), dir, func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
+		return l, target, nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer m.Close()
+	target := pendingTarget()
+	m.Prepare([]sesame.Target{target})
+	awaitPreparation(t, m, "ready")
+	if err = os.Mkdir(filepath.Join(dir, "physical-locks.json"), 0700); err != nil {
+		t.Fatal(err)
+	}
+	if _, err = m.Add(context.Background(), Enrollment{Name: "Door", Target: target, Mode: "register"}); err == nil {
+		t.Fatal("accepted registry failure")
+	}
+	id, _ := identity(target)
+	if _, err = m.readKey(id); err != nil {
+		t.Fatal("recovery key lost", err)
+	}
+	if !l.closed.Load() || len(m.RegistrationConnections()) != 0 || m.Has(id) {
+		t.Fatal("failed commit leaked ownership")
+	}
+}