commit c893a65e99c2d0da3110bca8e6a3353f3076bafb
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-15T05:25:28Z |
| subject | Update 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&®istering)$('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'&®istrationFor(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 := ®istrationLink{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")
+ }
+}