penguin/homekey-go

homekey-go

commit d42f3aa12b07a43af4dee3655967c9c1c35136b7

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-07T11:51:44Z
subjectAdded support for sesame locks
commit d42f3aa12b07a43af4dee3655967c9c1c35136b7
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-07T11:51:44Z

    Added support for sesame locks
---
 .dockerignore                     |   2 +
 Dockerfile                        |   2 +
 NOTICE                            |   8 +
 README.md                         |   9 +
 cmd/homekey/main.go               |  17 ++
 config.example.json               |   3 +-
 drivers/sesame/LICENSE            |  21 +++
 drivers/sesame/README.md          |  52 ++++++
 drivers/sesame/bluez.go           | 353 +++++++++++++++++++++++++++++++++++
 drivers/sesame/client.go          | 379 ++++++++++++++++++++++++++++++++++++++
 drivers/sesame/client_test.go     | 257 ++++++++++++++++++++++++++
 drivers/sesame/crypto.go          | 179 ++++++++++++++++++
 drivers/sesame/protocol.go        | 196 ++++++++++++++++++++
 drivers/sesame/protocol_test.go   | 150 +++++++++++++++
 ha-app/DOCS.md                    |  38 ++++
 internal/app/app_test.go          |   9 +
 internal/app/config.go            |  21 ++-
 internal/app/page.html            |  43 ++++-
 internal/app/pairing.go           |  14 +-
 internal/app/physical.go          | 149 +++++++++++++++
 internal/app/physical_test.go     |  75 ++++++++
 internal/app/reader.go            |  45 ++++-
 internal/app/settings.go          |   9 +
 internal/app/settings_test.go     |  17 +-
 internal/app/web.go               |   1 +
 internal/physical/manager.go      | 303 ++++++++++++++++++++++++++++++
 internal/physical/manager_test.go | 154 ++++++++++++++++
 scripts/stage-ha-app.sh           |   2 +-
 28 files changed, 2480 insertions(+), 28 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index 8b17b93..0bc9bcd 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,6 +5,8 @@
 !cmd/**
 !internal/
 !internal/**
+!drivers/
+!drivers/**
 !ha-app/
 !ha-app/run.sh
 !LICENSE
diff --git a/Dockerfile b/Dockerfile
index 704afa5..f30533c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,6 +6,7 @@ COPY go.mod go.sum ./
 RUN go mod download
 COPY cmd ./cmd
 COPY internal ./internal
+COPY drivers ./drivers
 RUN CGO_ENABLED=0 go build -mod=readonly -buildvcs=false -trimpath \
     -ldflags='-s -w' -o /out/homekey ./cmd/homekey
 
@@ -28,6 +29,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
 COPY --from=builder /out/homekey /usr/local/bin/homekey
 COPY ha-app/run.sh /usr/local/bin/homekey-entrypoint
 COPY LICENSE NOTICE /usr/share/doc/homekey-go/
+COPY drivers/sesame/LICENSE /usr/share/doc/homekey-go/sesame-LICENSE
 RUN chmod 0755 /usr/local/bin/homekey-entrypoint
 WORKDIR /data
 ENTRYPOINT ["/usr/local/bin/homekey-entrypoint"]
diff --git a/NOTICE b/NOTICE
index d13a95a..90c682f 100644
--- a/NOTICE
+++ b/NOTICE
@@ -27,3 +27,11 @@ notices are retained. Locally modified files carry notices; the changes are
 recorded in patches/hap.patch. Local regression tests are in
 third_party/hap/homekey_patch_test.go. Generator, example and image assets
 not needed for building the library were omitted.
+
+Sesame OS3 BLE protocol and registration/calibration behavior adapted from:
+  CANDY HOUSE SesameSDK_Android_with_DemoApp
+  Commit d12f23c25bffd5adf6361abd0db82bb144441875
+  Copyright (c) 2014-2026 CANDY HOUSE
+  MIT License; full notice retained in drivers/sesame/LICENSE.
+Changes: standalone Go implementation with BlueZ transport, bounded framing,
+strict session validation, private credential storage and web setup controls.
diff --git a/README.md b/README.md
index 06229aa..f8babeb 100644
--- a/README.md
+++ b/README.md
@@ -176,3 +176,12 @@ public Home group ID over BLE on reconnect and after provisioning changes; the
 ESP32 emits ECP before activating a card so iOS can route NFC to Home Key. The
 phone's Express Mode setting governs whether user approval is required.
 See [NFC deployment instructions](ha-app/NFC.md).
+
+## Sesame physical locks
+
+The web UI can scan and enroll Sesame 5/5 Pro/6/6 Pro locks, import an existing
+local key, capture lock/unlock positions and the supported boundary, and assign
+physical locks to Home Key locks. See [setup instructions](ha-app/DOCS.md#physical-locks-sesame)
+and [driver notes](drivers/sesame/README.md). Credentials stay in private state;
+`drivers/sesame` owns the protocol and direct BlueZ transport. Real-device
+registration, calibration and radio behavior still require hardware validation.
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
index 4f7220b..997f938 100644
--- a/cmd/homekey/main.go
+++ b/cmd/homekey/main.go
@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"homekey.local/provisioner/internal/app"
 	"homekey.local/provisioner/internal/blegateway"
+	"homekey.local/provisioner/internal/physical"
 	"homekey.local/provisioner/internal/provision"
 	"log"
 	"net"
@@ -115,7 +116,23 @@ func serve(processCtx context.Context, state, webAddr string, ingress, status bo
 	}
 	ctx, cancel := context.WithCancel(processCtx)
 	defer cancel()
+	physicalLocks, err := physical.Open(ctx, state, nil)
+	if err != nil {
+		return err
+	}
+	defer physicalLocks.Close()
+	settings.Physical = physicalLocks
+	for _, l := range locks {
+		for _, id := range l.Config.PhysicalLocks {
+			if !physicalLocks.Has(id) {
+				return fmt.Errorf("unknown physical lock %s assigned to %s", id, l.Config.ID)
+			}
+		}
+	}
 	var wg sync.WaitGroup
+	updatePhysical := app.BindPhysical(locks, physicalLocks)
+	wg.Add(1)
+	go func() { defer wg.Done(); updatePhysical(ctx) }()
 	failures := make(chan error, len(locks)+1)
 	for _, l := range locks {
 		wg.Add(1)
diff --git a/config.example.json b/config.example.json
index 6276fa0..573e746 100644
--- a/config.example.json
+++ b/config.example.json
@@ -6,7 +6,8 @@
       "name": "Go Home Key",
       "port": 51826,
       "finish": "silver",
-      "readers": ["porch"]
+      "readers": ["porch"],
+      "physical_locks": []
     }
   ],
   "readers": [
diff --git a/drivers/sesame/LICENSE b/drivers/sesame/LICENSE
new file mode 100644
index 0000000..30915f8
--- /dev/null
+++ b/drivers/sesame/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2014-2026 CANDY HOUSE
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/drivers/sesame/README.md b/drivers/sesame/README.md
new file mode 100644
index 0000000..473d31f
--- /dev/null
+++ b/drivers/sesame/README.md
@@ -0,0 +1,52 @@
+# Sesame OS3 driver
+
+This directory contains the Sesame protocol, BlueZ transport and hardware-free
+tests. Supported advertised models: Sesame 5, 5 Pro, 6 and 6 Pro. It does not
+implement Sesame OS2, Hub/Matter setup, cloud guest-key signing or firmware updates.
+
+The protocol is adapted from the MIT-licensed CANDY HOUSE Android SDK at
+`d12f23c25bffd5adf6361abd0db82bb144441875`. See `LICENSE` and the root `NOTICE`.
+
+## Integration
+
+`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
+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.
+
+`Client.Run` maintains a connection and reconnects after transport or crypto
+failure. `Action` serializes operations on that connection and waits for the
+lock's result code. Failed/timed-out operations are not replayed after reconnect.
+`State` returns the latest immutable snapshot; pointers in snapshots must not be
+modified by callers. Each reconnect clears state until new publications arrive.
+
+Commands: `lock`, `unlock`, `set-lock`, `set-unlock`, `set-boundary`. Calibration
+captures the current reported position; saving either endpoint preserves the
+other endpoint. Boundary capture is allowed only after firmware has published
+its boundary capability. Positions use the SDK's signed 16-bit angle values;
+negative values, including -1, are valid.
+
+OS3 framing uses 19 payload bytes per GATT fragment. Local login uses AES-CMAC.
+The fixed CCM profile is 13-byte nonce, 4-byte tag, AAD 00; the nonce contains an
+8-byte LE counter, zero, and a 4-byte token. Unsupported token lengths fail
+explicitly. No keys, authentication data or decrypted packets are logged.
+
+`Link`/`Dialer` separate the session protocol from BlueZ so a future ESP32 GATT
+tunnel can reuse this implementation. This release requires the HA/server's
+local Bluetooth adapter to reach the lock; it does not use HA Bluetooth proxies
+or add an ESP32 central role.
+
+## Validation
+
+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.
+
+Still requires real hardware validation: local BLE registration/login, initial
+token length and response encryption, notifications during manual movement,
+calibration/motor behavior, coexistence with the phone app, and battery impact
+of a persistent connection. Unit tests do not establish those device behaviors.
diff --git a/drivers/sesame/bluez.go b/drivers/sesame/bluez.go
new file mode 100644
index 0000000..43e9c37
--- /dev/null
+++ b/drivers/sesame/bluez.go
@@ -0,0 +1,353 @@
+// SPDX-License-Identifier: Apache-2.0
+package sesame
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"regexp"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/godbus/dbus/v5"
+)
+
+type Link interface {
+	Write(context.Context, []byte) error
+	Packets() <-chan []byte
+	Done() <-chan error
+	Close()
+}
+type Dialer func(context.Context, Target) (Link, Target, error)
+type Adapter struct {
+	ID      string `json:"id"`
+	Name    string `json:"name"`
+	Powered bool   `json:"powered"`
+}
+type Discovery struct {
+	Adapters []Adapter `json:"adapters"`
+	Locks    []Target  `json:"locks"`
+	Warnings []string  `json:"warnings"`
+}
+type objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+func getObjects(ctx context.Context, c *dbus.Conn) (objects, error) {
+	var o objects
+	e := c.Object("org.bluez", "/").CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&o)
+	return o, e
+}
+func value(p map[string]dbus.Variant, k string) string { s, _ := p[k].Value().(string); return s }
+
+var adapterPattern = regexp.MustCompile(`^hci[0-9]+$`)
+
+func ValidateTarget(t Target) error {
+	if _, e := NormalizeUUID(t.UUID); e != nil {
+		return e
+	}
+	if !Supported(t.Model) {
+		return errors.New("unsupported Sesame model")
+	}
+	if !adapterPattern.MatchString(t.Adapter) {
+		return errors.New("invalid Bluetooth adapter")
+	}
+	return nil
+}
+func advertised(p map[string]dbus.Variant) (Target, bool) {
+	// BlueZ strips the two-byte company identifier. Do not accept arbitrary
+	// manufacturer payloads merely because their first byte matches a model.
+	md, ok := p["ManufacturerData"].Value().(map[uint16]dbus.Variant)
+	if !ok {
+		return Target{}, false
+	}
+	v, ok := md[0x055a]
+	if !ok {
+		return Target{}, false
+	}
+	b, ok := v.Value().([]byte)
+	if !ok {
+		return Target{}, false
+	}
+	t, ok := Advertisement(b)
+	if !ok {
+		return Target{}, false
+	}
+	a, _ := p["Adapter"].Value().(dbus.ObjectPath)
+	parts := strings.Split(string(a), "/")
+	t.Adapter = parts[len(parts)-1]
+	t.Address = value(p, "Address")
+	return t, true
+}
+func snapshot(o objects) Discovery {
+	d := Discovery{Adapters: []Adapter{}, Locks: []Target{}, Warnings: []string{}}
+	for path, ifs := range o {
+		if p, ok := ifs["org.bluez.Adapter1"]; ok {
+			v, _ := p["Powered"].Value().(bool)
+			parts := strings.Split(string(path), "/")
+			d.Adapters = append(d.Adapters, Adapter{parts[len(parts)-1], value(p, "Alias"), v})
+		}
+		if p, ok := ifs["org.bluez.Device1"]; ok {
+			if t, ok := advertised(p); ok {
+				d.Locks = append(d.Locks, t)
+			}
+		}
+	}
+	sort.Slice(d.Adapters, func(i, j int) bool { return d.Adapters[i].ID < d.Adapters[j].ID })
+	sort.Slice(d.Locks, func(i, j int) bool { return d.Locks[i].Adapter+d.Locks[i].UUID < d.Locks[j].Adapter+d.Locks[j].UUID })
+	return d
+}
+func pause(ctx context.Context, d time.Duration) error {
+	t := time.NewTimer(d)
+	defer t.Stop()
+	select {
+	case <-t.C:
+		return nil
+	case <-ctx.Done():
+		return ctx.Err()
+	}
+}
+func stopScan(a dbus.BusObject) {
+	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+	defer cancel()
+	_ = a.CallWithContext(ctx, "org.bluez.Adapter1.StopDiscovery", 0).Err
+}
+func Discover(ctx context.Context) (Discovery, error) {
+	c, e := dbus.ConnectSystemBus()
+	if e != nil {
+		return Discovery{}, fmt.Errorf("Bluetooth unavailable: %w", e)
+	}
+	defer c.Close()
+	o, e := getObjects(ctx, c)
+	if e != nil {
+		return Discovery{}, e
+	}
+	d := snapshot(o)
+	var active []dbus.BusObject
+	defer func() {
+		for _, a := range active {
+			stopScan(a)
+		}
+	}()
+	for _, a := range d.Adapters {
+		if !a.Powered {
+			d.Warnings = append(d.Warnings, a.ID+" is switched off")
+			continue
+		}
+		obj := c.Object("org.bluez", dbus.ObjectPath("/org/bluez/"+a.ID))
+		if e := obj.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; e != nil {
+			d.Warnings = append(d.Warnings, a.ID+": "+e.Error())
+		} else {
+			active = append(active, obj)
+		}
+	}
+	if len(active) > 0 {
+		if e := pause(ctx, 5*time.Second); e != nil {
+			return Discovery{}, e
+		}
+	}
+	o, e = getObjects(ctx, c)
+	if e != nil {
+		return Discovery{}, e
+	}
+	out := snapshot(o)
+	out.Warnings = d.Warnings
+	if len(out.Adapters) == 0 {
+		out.Warnings = append(out.Warnings, "No local Bluetooth adapters found")
+	}
+	return out, nil
+}
+
+type bluezLink struct {
+	conn          *dbus.Conn
+	device, write dbus.BusObject
+	mode          string
+	packets       chan []byte
+	done          chan error
+	cancel        context.CancelFunc
+	once          sync.Once
+}
+
+func (b *bluezLink) Packets() <-chan []byte { return b.packets }
+func (b *bluezLink) Done() <-chan error     { return b.done }
+func (b *bluezLink) Write(ctx context.Context, data []byte) error {
+	return b.write.CallWithContext(ctx, "org.bluez.GattCharacteristic1.WriteValue", 0, data, map[string]dbus.Variant{"type": dbus.MakeVariant(b.mode)}).Err
+}
+func (b *bluezLink) Close() {
+	b.once.Do(func() {
+		b.cancel()
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		defer cancel()
+		_ = b.device.CallWithContext(ctx, "org.bluez.Device1.Disconnect", 0).Err
+		b.conn.Close()
+	})
+}
+func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
+	if e := ValidateTarget(t); e != nil {
+		return nil, Target{}, e
+	}
+	c, e := dbus.ConnectSystemBus()
+	if e != nil {
+		return nil, Target{}, e
+	}
+	success := false
+	var device dbus.BusObject
+	owned := false
+	defer func() {
+		if !success {
+			if owned {
+				cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+				defer cancel()
+				_ = device.CallWithContext(cleanup, "org.bluez.Device1.Disconnect", 0).Err
+			}
+			c.Close()
+		}
+	}()
+	adapter := c.Object("org.bluez", dbus.ObjectPath("/org/bluez/"+t.Adapter))
+	if e = adapter.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; e != nil {
+		return nil, Target{}, e
+	}
+	defer stopScan(adapter)
+	var path dbus.ObjectPath
+	var found Target
+	for path == "" {
+		o, e := getObjects(ctx, c)
+		if e != nil {
+			return nil, Target{}, e
+		}
+		for p, ifs := range o {
+			props, ok := ifs["org.bluez.Device1"]
+			if !ok {
+				continue
+			}
+			candidate, ok := advertised(props)
+			if ok && candidate.UUID == t.UUID && candidate.Adapter == t.Adapter {
+				if candidate.Model != t.Model {
+					return nil, Target{}, errors.New("Sesame model changed; scan again")
+				}
+				connected, _ := props["Connected"].Value().(bool)
+				if connected {
+					return nil, Target{}, errors.New("Sesame is already connected through this adapter")
+				}
+				path = p
+				found = candidate
+				break
+			}
+		}
+		if path == "" {
+			if e = pause(ctx, 250*time.Millisecond); e != nil {
+				return nil, Target{}, fmt.Errorf("Sesame discovery: %w", e)
+			}
+		}
+	}
+	device = c.Object("org.bluez", path)
+	owned = true
+	if e = device.CallWithContext(ctx, "org.bluez.Device1.Connect", 0).Err; e != nil {
+		return nil, Target{}, e
+	}
+	var writePath, notifyPath dbus.ObjectPath
+	mode := ""
+	for writePath == "" || notifyPath == "" {
+		o, e := getObjects(ctx, c)
+		if e != nil {
+			return nil, Target{}, e
+		}
+		resolved, _ := o[path]["org.bluez.Device1"]["ServicesResolved"].Value().(bool)
+		if resolved {
+			for p, ifs := range o {
+				v, ok := ifs["org.bluez.GattCharacteristic1"]
+				if !ok {
+					continue
+				}
+				sp, _ := v["Service"].Value().(dbus.ObjectPath)
+				service := o[sp]["org.bluez.GattService1"]
+				owner, _ := service["Device"].Value().(dbus.ObjectPath)
+				if owner != path || !strings.EqualFold(value(service, "UUID"), ServiceUUID) {
+					continue
+				}
+				switch strings.ToLower(value(v, "UUID")) {
+				case WriteUUID:
+					flags, _ := v["Flags"].Value().([]string)
+					for _, f := range flags {
+						if f == "write" && mode == "" {
+							mode = "request"
+						}
+						if f == "write-without-response" {
+							mode = "command"
+						}
+					}
+					writePath = p
+				case NotifyUUID:
+					notifyPath = p
+				}
+			}
+		}
+		if writePath == "" || notifyPath == "" {
+			if e = pause(ctx, 100*time.Millisecond); e != nil {
+				return nil, Target{}, e
+			}
+		}
+	}
+	if mode == "" {
+		return nil, Target{}, errors.New("Sesame command characteristic is not writable")
+	}
+	signals := make(chan *dbus.Signal, 256)
+	c.Signal(signals)
+	if e = c.AddMatchSignal(dbus.WithMatchSender("org.bluez"), dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchPathNamespace(path)); e != nil {
+		return nil, Target{}, e
+	}
+	life, cancel := context.WithCancel(context.Background())
+	b := &bluezLink{conn: c, device: device, write: c.Object("org.bluez", writePath), mode: mode, packets: make(chan []byte, 128), done: make(chan error, 1), cancel: cancel}
+	go func() {
+		fail := func(e error) {
+			select {
+			case b.done <- e:
+			default:
+			}
+		}
+		defer c.RemoveSignal(signals)
+		for {
+			select {
+			case <-life.Done():
+				return
+			case sig, ok := <-signals:
+				if !ok {
+					fail(errors.New("Bluetooth bus disconnected"))
+					return
+				}
+				if sig == nil || len(sig.Body) < 2 {
+					continue
+				}
+				props, ok := sig.Body[1].(map[string]dbus.Variant)
+				if !ok {
+					continue
+				}
+				if sig.Path == path {
+					if v, exists := props["Connected"]; exists {
+						on, _ := v.Value().(bool)
+						if !on {
+							fail(errors.New("Sesame disconnected"))
+							return
+						}
+					}
+				}
+				if sig.Path == notifyPath {
+					if data, ok := props["Value"].Value().([]byte); ok {
+						select {
+						case b.packets <- append([]byte(nil), data...):
+						default:
+							fail(errors.New("Sesame notification queue overflow"))
+							return
+						}
+					}
+				}
+			}
+		}
+	}()
+	if e = c.Object("org.bluez", notifyPath).CallWithContext(ctx, "org.bluez.GattCharacteristic1.StartNotify", 0).Err; e != nil {
+		cancel()
+		return nil, Target{}, e
+	}
+	success = true
+	return b, found, nil
+}
diff --git a/drivers/sesame/client.go b/drivers/sesame/client.go
new file mode 100644
index 0000000..03fa674
--- /dev/null
+++ b/drivers/sesame/client.go
@@ -0,0 +1,379 @@
+// SPDX-License-Identifier: Apache-2.0
+package sesame
+
+import (
+	"context"
+	"crypto/ecdh"
+	"crypto/rand"
+	"encoding/binary"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+)
+
+type session struct {
+	link   Link
+	rx     assembler
+	cipher *sessionCipher
+	state  State
+	update func(State)
+}
+
+func (s *session) publish() {
+	if s.update != nil {
+		s.update(s.state)
+	}
+}
+func (s *session) decode(fragment []byte) ([]byte, error) {
+	kind, b, e := s.rx.feed(fragment)
+	if e != nil || kind == 0 {
+		return nil, e
+	}
+	if kind == 2 {
+		if s.cipher == nil {
+			return nil, errors.New("encrypted message before Sesame session")
+		}
+		return s.cipher.decrypt(b)
+	}
+	if s.cipher != nil {
+		return nil, errors.New("plaintext message in authenticated Sesame session")
+	}
+	return b, nil
+}
+func (s *session) next(ctx context.Context) ([]byte, error) {
+	for {
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case e := <-s.link.Done():
+			if e == nil {
+				e = errors.New("Sesame connection closed")
+			}
+			return nil, e
+		case p, ok := <-s.link.Packets():
+			if !ok {
+				return nil, errors.New("Sesame notifications closed")
+			}
+			b, e := s.decode(p)
+			if e != nil {
+				return nil, e
+			}
+			if b != nil {
+				return b, nil
+			}
+		}
+	}
+}
+func (s *session) handle(b []byte) error {
+	if len(b) < 2 {
+		return errors.New("short Sesame notification")
+	}
+	if b[0] != 8 {
+		return errors.New("unexpected Sesame response")
+	}
+	if b[1] == 14 {
+		return errors.New("Sesame session restarted")
+	}
+	if e := s.state.publish(b[1], b[2:]); e != nil {
+		return e
+	}
+	s.publish()
+	return nil
+}
+func (s *session) initial(ctx context.Context) ([]byte, error) {
+	b, e := s.next(ctx)
+	if e != nil {
+		return nil, e
+	}
+	if len(b) != 6 || b[0] != 8 || b[1] != 14 {
+		return nil, errors.New("expected four-byte Sesame initial token")
+	}
+	return b[2:], nil
+}
+func (s *session) exchange(ctx context.Context, item byte, payload []byte, encrypted bool) ([]byte, error) {
+	if e := ctx.Err(); e != nil {
+		return nil, e
+	}
+	b := append([]byte{item}, payload...)
+	kind := byte(1)
+	if encrypted {
+		if s.cipher == nil {
+			return nil, errors.New("Sesame not authenticated")
+		}
+		var e error
+		b, e = s.cipher.encrypt(b)
+		if e != nil {
+			return nil, e
+		}
+		kind = 2
+	}
+	fs, e := fragments(kind, b)
+	if e != nil {
+		return nil, e
+	}
+	for _, f := range fs {
+		if e = s.link.Write(ctx, f); e != nil {
+			return nil, e
+		}
+	}
+	for {
+		b, e := s.next(ctx)
+		if e != nil {
+			return nil, e
+		}
+		if len(b) < 2 {
+			return nil, errors.New("short Sesame response")
+		}
+		if b[0] == 7 {
+			if len(b) < 3 || b[1] != item {
+				return nil, errors.New("unexpected Sesame response item")
+			}
+			if b[2] != 0 {
+				return nil, &ResultError{item, b[2]}
+			}
+			return b[3:], nil
+		}
+		if e = s.handle(b); e != nil {
+			return nil, e
+		}
+	}
+}
+func login(ctx context.Context, l Link, c Credential, update func(State)) (*session, error) {
+	s := &session{link: l, update: update}
+	token, e := s.initial(ctx)
+	if e != nil {
+		return nil, e
+	}
+	key, e := c.key()
+	if e != nil {
+		return nil, e
+	}
+	var auth []byte
+	s.cipher, auth, e = newCipher(key, token)
+	if e != nil {
+		return nil, e
+	}
+	reply, e := s.exchange(ctx, 2, auth[:4], false)
+	if e != nil {
+		return nil, fmt.Errorf("Sesame login: %w", e)
+	}
+	if len(reply) < 4 {
+		return nil, errors.New("short Sesame login response")
+	}
+	s.state.Online = true
+	s.state.Updated = time.Now()
+	s.publish()
+	return s, nil
+}
+
+// Register enrolls only a device advertising as unregistered. Persist is invoked
+// immediately after deriving the credential, before any additional BLE command.
+// A registered device must instead be imported; this API never resets a lock.
+func Register(ctx context.Context, t Target, dial Dialer, persist func(Credential) error) error {
+	l, actual, e := dial(ctx, t)
+	if e != nil {
+		return e
+	}
+	defer l.Close()
+	if actual.Registered {
+		return errors.New("lock already registered: import its local key instead")
+	}
+	s := &session{link: l}
+	if _, e = s.initial(ctx); e != nil {
+		return e
+	}
+	private, e := ecdh.P256().GenerateKey(rand.Reader)
+	if e != nil {
+		return e
+	}
+	payload := append([]byte(nil), private.PublicKey().Bytes()[1:]...)
+	stamp := make([]byte, 4)
+	binary.LittleEndian.PutUint32(stamp, uint32(time.Now().Unix()))
+	payload = append(payload, stamp...)
+	reply, e := s.exchange(ctx, 1, payload, false)
+	if e != nil {
+		return e
+	}
+	if len(reply) < 77 {
+		return 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")
+	}
+	shared, e := private.ECDH(pub)
+	if e != nil {
+		return e
+	}
+	return persist(Credential{Secret: hex.EncodeToString(shared[:16])})
+}
+func Probe(ctx context.Context, t Target, c Credential, dial Dialer) error {
+	l, actual, e := dial(ctx, t)
+	if e != nil {
+		return e
+	}
+	defer l.Close()
+	if !actual.Registered {
+		return errors.New("lock is unregistered; register it first")
+	}
+	_, e = login(ctx, l, c, nil)
+	return e
+}
+
+type operation struct {
+	ctx   context.Context
+	name  string
+	reply chan error
+}
+type Client struct {
+	target     Target
+	credential Credential
+	dial       Dialer
+	ops        chan operation
+	mu         sync.RWMutex
+	state      State
+}
+
+func NewClient(t Target, c Credential, dial Dialer) *Client {
+	return &Client{target: t, credential: c, dial: dial, ops: make(chan operation)}
+}
+func (c *Client) State() State { c.mu.RLock(); defer c.mu.RUnlock(); return c.state }
+func (c *Client) set(s State)  { c.mu.Lock(); c.state = s; c.mu.Unlock() }
+func (c *Client) Action(ctx context.Context, name string) error {
+	switch name {
+	case "lock", "unlock", "set-lock", "set-unlock", "set-boundary":
+	default:
+		return errors.New("unsupported Sesame action")
+	}
+	if !c.State().Online {
+		return errors.New("physical lock is offline")
+	}
+	op := operation{ctx, name, make(chan error, 1)}
+	select {
+	case c.ops <- op:
+	case <-ctx.Done():
+		return ctx.Err()
+	}
+	select {
+	case e := <-op.reply:
+		return e
+	case <-ctx.Done():
+		return ctx.Err()
+	}
+}
+func (c *Client) Run(ctx context.Context) {
+	defer c.set(State{Error: "physical lock stopped", Updated: time.Now()})
+	for ctx.Err() == nil {
+		e := c.runSession(ctx)
+		msg := "physical lock disconnected"
+		if e != nil {
+			msg = e.Error()
+		}
+		c.set(State{Error: msg, Updated: time.Now()})
+		if pause(ctx, 3*time.Second) != nil {
+			return
+		}
+	}
+}
+func (c *Client) runSession(ctx context.Context) error {
+	connect, cancel := context.WithTimeout(ctx, 20*time.Second)
+	l, actual, e := c.dial(connect, c.target)
+	if e != nil {
+		cancel()
+		return e
+	}
+	defer l.Close()
+	if !actual.Registered {
+		cancel()
+		return errors.New("Sesame was reset; enroll it again")
+	}
+	s, e := login(connect, l, c.credential, c.set)
+	cancel()
+	if e != nil {
+		return e
+	}
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case e := <-l.Done():
+			return e
+		case p, ok := <-l.Packets():
+			if !ok {
+				return errors.New("Sesame notification stream ended")
+			}
+			b, e := s.decode(p)
+			if e != nil {
+				return e
+			}
+			if b != nil {
+				if e = s.handle(b); e != nil {
+					return e
+				}
+			}
+		case op := <-c.ops:
+			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)
+			stop()
+			cancel()
+			op.reply <- e
+			var rejected *ResultError
+			if e != nil && !errors.As(e, &rejected) {
+				return e
+			}
+		}
+	}
+}
+func (s *session) action(ctx context.Context, name string) error {
+	var item byte
+	var payload []byte
+	switch name {
+	case "lock":
+		item = 82
+		payload = []byte{0, 14}
+	case "unlock":
+		item = 83
+		payload = []byte{0, 14}
+	case "set-lock", "set-unlock":
+		if s.state.Position == nil || s.state.LockPosition == nil || s.state.UnlockPosition == nil {
+			return &ResultError{80, 6}
+		}
+		locked, unlocked := *s.state.LockPosition, *s.state.UnlockPosition
+		if name == "set-lock" {
+			locked = *s.state.Position
+		} else {
+			unlocked = *s.state.Position
+		}
+		item = 80
+		payload = append(short(locked), short(unlocked)...)
+	case "set-boundary":
+		if s.state.Boundary == nil || s.state.Position == nil {
+			return &ResultError{20, 2}
+		}
+		item = 20
+		payload = short(*s.state.Position)
+	default:
+		return errors.New("unsupported Sesame action")
+	}
+	_, e := s.exchange(ctx, item, payload, true)
+	if e != nil {
+		return e
+	}
+	if item == 80 {
+		s.state.LockPosition = i16(payload[:2])
+		s.state.UnlockPosition = i16(payload[2:])
+	}
+	if item == 20 {
+		s.state.Boundary = i16(payload)
+	}
+	s.publish()
+	return nil
+}
diff --git a/drivers/sesame/client_test.go b/drivers/sesame/client_test.go
new file mode 100644
index 0000000..d200181
--- /dev/null
+++ b/drivers/sesame/client_test.go
@@ -0,0 +1,257 @@
+package sesame
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdh"
+	"crypto/rand"
+	"encoding/hex"
+	"errors"
+	"sync"
+	"testing"
+	"time"
+)
+
+type fakeLink struct {
+	packets chan []byte
+	done    chan error
+	rx      assembler
+	write   func(byte, []byte) error
+	mu      sync.Mutex
+	closed  bool
+}
+
+func newLink() *fakeLink {
+	f := &fakeLink{packets: make(chan []byte, 64), done: make(chan error, 1)}
+	f.emit(1, []byte{8, 14, 0x11, 0x22, 0x33, 0x44})
+	return f
+}
+func (f *fakeLink) emit(kind byte, b []byte) {
+	fs, _ := fragments(kind, b)
+	for _, v := range fs {
+		f.packets <- v
+	}
+}
+func (f *fakeLink) Write(ctx context.Context, b []byte) error {
+	if e := ctx.Err(); e != nil {
+		return e
+	}
+	kind, p, e := f.rx.feed(b)
+	if e != nil {
+		return e
+	}
+	if kind != 0 {
+		return f.write(kind, p)
+	}
+	return nil
+}
+func (f *fakeLink) Packets() <-chan []byte { return f.packets }
+func (f *fakeLink) Done() <-chan error     { return f.done }
+func (f *fakeLink) Close()                 { f.mu.Lock(); f.closed = true; f.mu.Unlock() }
+
+var testTarget = Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0"}
+var testKey = Credential{Secret: "000102030405060708090a0b0c0d0e0f"}
+
+func readySession(t *testing.T) (*session, *fakeLink, *sessionCipher) {
+	t.Helper()
+	f := newLink()
+	key, _ := testKey.key()
+	peer, auth, _ := newCipher(key, unhex("11223344"))
+	f.write = func(kind byte, b []byte) error {
+		if kind != 1 || !bytes.Equal(b, append([]byte{2}, auth[:4]...)) {
+			return errors.New("wrong login")
+		}
+		reply, _ := peer.encrypt([]byte{7, 2, 0, 0, 0, 0, 0})
+		f.emit(2, reply)
+		return nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	s, e := login(ctx, f, testKey, nil)
+	if e != nil {
+		t.Fatal(e)
+	}
+	return s, f, peer
+}
+func TestLoginCalibrationAndCommandAcknowledgement(t *testing.T) {
+	s, f, peer := readySession(t)
+	s.state.publish(81, []byte{0, 0, 0, 128, 45, 0, 16})
+	s.state.publish(80, []byte{90, 0, 0, 0, 0, 0})
+	s.state.publish(20, []byte{30, 0})
+	commands := [][]byte{{80, 45, 0, 0, 0}, {80, 45, 0, 45, 0}, {20, 45, 0}, {83, 0, 14}, {82, 0, 14}}
+	i := 0
+	f.write = func(kind byte, b []byte) error {
+		if kind != 2 {
+			return errors.New("plaintext command")
+		}
+		p, e := peer.decrypt(b)
+		if e != nil {
+			return e
+		}
+		if !bytes.Equal(p, commands[i]) {
+			t.Errorf("command %d: %x", i, p)
+		}
+		i++
+		reply, _ := peer.encrypt([]byte{7, p[0], 0})
+		f.emit(2, reply)
+		return nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	for _, action := range []string{"set-lock", "set-unlock", "set-boundary", "unlock", "lock"} {
+		if e := s.action(ctx, action); e != nil {
+			t.Fatal(e)
+		}
+	}
+	if s.state.Locked {
+		t.Fatal("command fabricated physical lock state")
+	}
+	if *s.state.Boundary != 45 {
+		t.Fatal("boundary not updated")
+	}
+	f.write = func(kind byte, b []byte) error {
+		p, e := peer.decrypt(b)
+		if e != nil {
+			return e
+		}
+		reply, _ := peer.encrypt([]byte{7, p[0], 7})
+		f.emit(2, reply)
+		return nil
+	}
+	var rejected *ResultError
+	if e := s.action(ctx, "unlock"); !errors.As(e, &rejected) || rejected.Code != 7 {
+		t.Fatal("did not propagate BUSY", e)
+	}
+}
+func TestSessionRejectsTamperingAndPlaintext(t *testing.T) {
+	s, _, peer := readySession(t)
+	cipher, _ := peer.encrypt([]byte{8, 81, 0, 0, 0, 128, 1, 0, 16})
+	cipher[len(cipher)-1] ^= 1
+	fs, _ := fragments(2, cipher)
+	if _, e := s.decode(fs[0]); e == nil {
+		t.Fatal("accepted tampered notification")
+	}
+	fs, _ = fragments(1, []byte{8, 81, 0, 0, 0, 128, 1, 0, 16})
+	if _, e := s.decode(fs[0]); e == nil {
+		t.Fatal("accepted plaintext status")
+	}
+}
+func TestRegistrationAndRegisteredDeviceRefusal(t *testing.T) {
+	f := newLink()
+	device, _ := ecdh.P256().GenerateKey(rand.Reader)
+	var want string
+	f.write = func(kind byte, p []byte) error {
+		if kind != 1 || len(p) != 69 || p[0] != 1 {
+			return errors.New("invalid registration command")
+		}
+		pub, e := ecdh.P256().NewPublicKey(append([]byte{4}, p[1:65]...))
+		if e != nil {
+			return e
+		}
+		shared, e := device.ECDH(pub)
+		if e != nil {
+			return e
+		}
+		want = hex.EncodeToString(shared[:16])
+		reply := make([]byte, 77)
+		copy(reply[13:], device.PublicKey().Bytes()[1:])
+		f.emit(1, append([]byte{7, 1, 0}, reply...))
+		return nil
+	}
+	dial := func(context.Context, Target) (Link, Target, error) { return f, testTarget, nil }
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	saved := ""
+	if e := Register(ctx, testTarget, dial, func(c Credential) error { saved = c.Secret; return nil }); e != nil || saved == "" || saved != want {
+		t.Fatal("credential derivation", e)
+	}
+	registered := testTarget
+	registered.Registered = true
+	f = newLink()
+	f.write = func(byte, []byte) error { t.Fatal("wrote to registered device"); return nil }
+	dial = func(context.Context, Target) (Link, Target, error) { return f, registered, nil }
+	if e := Register(ctx, registered, dial, func(Credential) error { t.Fatal("overwrote credential"); return nil }); e == nil {
+		t.Fatal("registered an owned lock")
+	}
+}
+func TestMissingBoundaryDoesNotWrite(t *testing.T) {
+	s, f, _ := readySession(t)
+	f.write = func(byte, []byte) error { t.Fatal("unsupported setting sent"); return nil }
+	if e := s.action(context.Background(), "set-boundary"); e == nil {
+		t.Fatal("accepted unsupported boundary")
+	}
+}
+func TestClientCancellation(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	dial := func(ctx context.Context, _ Target) (Link, Target, error) {
+		<-ctx.Done()
+		return nil, Target{}, ctx.Err()
+	}
+	c := NewClient(testTarget, testKey, dial)
+	done := make(chan struct{})
+	go func() { c.Run(ctx); close(done) }()
+	cancel()
+	select {
+	case <-done:
+	case <-time.After(time.Second):
+		t.Fatal("client did not stop")
+	}
+	if c.State().Online {
+		t.Fatal("online after stop")
+	}
+}
+
+func TestClientReceivesManualStateAndDoesNotReplay(t *testing.T) {
+	f := newLink()
+	key, _ := testKey.key()
+	peer, auth, _ := newCipher(key, unhex("11223344"))
+	writes := make(chan byte, 8)
+	f.write = func(kind byte, b []byte) error {
+		if kind == 1 {
+			if !bytes.Equal(b, append([]byte{2}, auth[:4]...)) {
+				return errors.New("login mismatch")
+			}
+			reply, _ := peer.encrypt([]byte{7, 2, 0, 0, 0, 0, 0})
+			f.emit(2, reply)
+			state, _ := peer.encrypt([]byte{8, 81, 0, 0, 0, 128, 90, 0, 18})
+			f.emit(2, state)
+			return nil
+		}
+		p, e := peer.decrypt(b)
+		if e != nil {
+			return e
+		}
+		writes <- p[0]
+		// Deliberately drop the acknowledgement. Client must invalidate this session.
+		return nil
+	}
+	actual := testTarget
+	actual.Registered = true
+	c := NewClient(testTarget, testKey, func(context.Context, Target) (Link, Target, error) { return f, actual, nil })
+	ctx, cancel := context.WithCancel(context.Background())
+	done := make(chan struct{})
+	go func() { c.Run(ctx); close(done) }()
+	defer func() { cancel(); <-done }()
+	deadline := time.Now().Add(time.Second)
+	for c.State().Position == nil && time.Now().Before(deadline) {
+		time.Sleep(time.Millisecond)
+	}
+	if s := c.State(); !s.Online || s.Position == nil || *s.Position != 90 || !s.Locked {
+		t.Fatalf("manual status not delivered: %+v", s)
+	}
+	request, stop := context.WithTimeout(ctx, 20*time.Millisecond)
+	defer stop()
+	if e := c.Action(request, "unlock"); e == nil {
+		t.Fatal("missing ack reported success")
+	}
+	deadline = time.Now().Add(time.Second)
+	for c.State().Online && time.Now().Before(deadline) {
+		time.Sleep(time.Millisecond)
+	}
+	if c.State().Online {
+		t.Fatal("timed-out crypto session remained online")
+	}
+	if len(writes) != 1 || <-writes != 83 {
+		t.Fatal("replayed or missed unlock")
+	}
+}
diff --git a/drivers/sesame/crypto.go b/drivers/sesame/crypto.go
new file mode 100644
index 0000000..bacb251
--- /dev/null
+++ b/drivers/sesame/crypto.go
@@ -0,0 +1,179 @@
+// SPDX-License-Identifier: Apache-2.0
+package sesame
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/subtle"
+	"encoding/binary"
+	"errors"
+	"math"
+)
+
+// RFC 4493 AES-CMAC (full 128-bit output).
+func cmac(key, data []byte) ([]byte, error) {
+	b, e := aes.NewCipher(key)
+	if e != nil {
+		return nil, e
+	}
+	double := func(x []byte) []byte {
+		r := make([]byte, 16)
+		var c byte
+		for i := 15; i >= 0; i-- {
+			r[i] = x[i]<<1 | c
+			c = x[i] >> 7
+		}
+		if c != 0 {
+			r[15] ^= 0x87
+		}
+		return r
+	}
+	l := make([]byte, 16)
+	b.Encrypt(l, l)
+	k1 := double(l)
+	k2 := double(k1)
+	n := (len(data) + 15) / 16
+	if n == 0 {
+		n = 1
+	}
+	last := make([]byte, 16)
+	copy(last, data[(n-1)*16:])
+	k := k1
+	if len(data) == 0 || len(data)%16 != 0 {
+		last[len(data)%16] = 0x80
+		k = k2
+	}
+	state := make([]byte, 16)
+	for i := 0; i < n-1; i++ {
+		for j := range state {
+			state[j] ^= data[i*16+j]
+		}
+		b.Encrypt(state, state)
+	}
+	for j := range state {
+		state[j] ^= last[j] ^ k[j]
+	}
+	b.Encrypt(state, state)
+	return state, nil
+}
+
+// Fixed OS3 CCM profile, NIST SP 800-38C: nonce 13, tag 4, L=2, AAD 00.
+// It deliberately supports only this protocol profile, not a general AEAD API.
+func ccmTag(b cipher.Block, nonce, plain []byte) []byte {
+	state := make([]byte, 16)
+	state[0] = 0x49
+	copy(state[1:], nonce)
+	binary.BigEndian.PutUint16(state[14:], uint16(len(plain)))
+	b.Encrypt(state, state)
+	// Encoded AAD: two-byte length 1, byte 00, then zero padding.
+	state[1] ^= 1
+	b.Encrypt(state, state)
+	for off := 0; off < len(plain); off += 16 {
+		n := len(plain) - off
+		if n > 16 {
+			n = 16
+		}
+		for i := 0; i < n; i++ {
+			state[i] ^= plain[off+i]
+		}
+		b.Encrypt(state, state)
+	}
+	return state[:4]
+}
+func ccmStream(b cipher.Block, nonce []byte, count uint16) []byte {
+	in := make([]byte, 16)
+	in[0] = 1
+	copy(in[1:], nonce)
+	binary.BigEndian.PutUint16(in[14:], count)
+	b.Encrypt(in, in)
+	return in
+}
+func ccmSeal(b cipher.Block, nonce, plain []byte) ([]byte, error) {
+	if len(nonce) != 13 || len(plain) > maxMessage-4 {
+		return nil, errors.New("invalid CCM input")
+	}
+	out := make([]byte, len(plain)+4)
+	tag := ccmTag(b, nonce, plain)
+	s0 := ccmStream(b, nonce, 0)
+	for off := 0; off < len(plain); off += 16 {
+		s := ccmStream(b, nonce, uint16(off/16+1))
+		for j := 0; j < 16 && off+j < len(plain); j++ {
+			out[off+j] = plain[off+j] ^ s[j]
+		}
+	}
+	for i := 0; i < 4; i++ {
+		out[len(plain)+i] = tag[i] ^ s0[i]
+	}
+	return out, nil
+}
+func ccmOpen(b cipher.Block, nonce, in []byte) ([]byte, error) {
+	if len(nonce) != 13 || len(in) < 4 || len(in) > maxMessage {
+		return nil, errors.New("invalid CCM input")
+	}
+	n := len(in) - 4
+	plain := make([]byte, n)
+	for off := 0; off < n; off += 16 {
+		s := ccmStream(b, nonce, uint16(off/16+1))
+		for j := 0; j < 16 && off+j < n; j++ {
+			plain[off+j] = in[off+j] ^ s[j]
+		}
+	}
+	tag := ccmTag(b, nonce, plain)
+	s0 := ccmStream(b, nonce, 0)
+	for i := 0; i < 4; i++ {
+		tag[i] ^= s0[i]
+	}
+	if subtle.ConstantTimeCompare(tag, in[n:]) != 1 {
+		return nil, errors.New("Sesame authentication tag failed")
+	}
+	return plain, nil
+}
+
+type sessionCipher struct {
+	block  cipher.Block
+	token  [4]byte
+	tx, rx uint64
+}
+
+func newCipher(secret, token []byte) (*sessionCipher, []byte, error) {
+	if len(secret) != 16 || len(token) != 4 {
+		return nil, nil, errors.New("unsupported Sesame key/token length")
+	}
+	auth, e := cmac(secret, token)
+	if e != nil {
+		return nil, nil, e
+	}
+	b, e := aes.NewCipher(auth)
+	if e != nil {
+		return nil, nil, e
+	}
+	s := &sessionCipher{block: b}
+	copy(s.token[:], token)
+	return s, auth, nil
+}
+func (s *sessionCipher) nonce(counter uint64) []byte {
+	n := make([]byte, 13)
+	binary.LittleEndian.PutUint64(n, counter)
+	copy(n[9:], s.token[:])
+	return n
+}
+func (s *sessionCipher) encrypt(b []byte) ([]byte, error) {
+	if s.tx == math.MaxUint64 {
+		return nil, errors.New("Sesame counter exhausted")
+	}
+	out, e := ccmSeal(s.block, s.nonce(s.tx), b)
+	if e == nil {
+		s.tx++
+	}
+	return out, e
+}
+func (s *sessionCipher) decrypt(b []byte) ([]byte, error) {
+	if s.rx == math.MaxUint64 {
+		return nil, errors.New("Sesame counter exhausted")
+	}
+	out, e := ccmOpen(s.block, s.nonce(s.rx), b)
+	if e == nil {
+		s.rx++
+	}
+	return out, e
+}
diff --git a/drivers/sesame/protocol.go b/drivers/sesame/protocol.go
new file mode 100644
index 0000000..61d82c3
--- /dev/null
+++ b/drivers/sesame/protocol.go
@@ -0,0 +1,196 @@
+// SPDX-License-Identifier: Apache-2.0
+// Protocol adapted from CANDY HOUSE's MIT-licensed Sesame Android SDK.
+package sesame
+
+import (
+	"encoding/binary"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+)
+
+const (
+	ServiceUUID = "0000fd81-0000-1000-8000-00805f9b34fb"
+	WriteUUID   = "16860002-a5ae-9856-b6d3-dbb4c676993e"
+	NotifyUUID  = "16860003-a5ae-9856-b6d3-dbb4c676993e"
+	maxMessage  = 1024
+)
+
+type Target struct {
+	UUID       string `json:"uuid"`
+	Model      string `json:"model"`
+	Adapter    string `json:"adapter"`
+	Address    string `json:"address"`
+	Registered bool   `json:"registered"`
+}
+
+// Credential is private state. Never include it in public configuration/status.
+type Credential struct {
+	Secret string `json:"secret"`
+}
+
+func (c Credential) key() ([]byte, error) {
+	b, e := hex.DecodeString(c.Secret)
+	if e != nil || len(b) != 16 {
+		return nil, errors.New("Sesame secret must contain exactly 32 hexadecimal characters")
+	}
+	return b, nil
+}
+func (c Credential) Validate() error { _, e := c.key(); return e }
+
+func NormalizeUUID(s string) (string, error) {
+	s = strings.ToLower(strings.TrimSpace(s))
+	if len(s) != 36 || s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
+		return "", errors.New("invalid Sesame UUID")
+	}
+	b, e := hex.DecodeString(strings.ReplaceAll(s, "-", ""))
+	if e != nil || len(b) != 16 {
+		return "", errors.New("invalid Sesame UUID")
+	}
+	return formatUUID(b), nil
+}
+func formatUUID(b []byte) string {
+	s := hex.EncodeToString(b)
+	return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
+}
+func model(product byte) string {
+	switch product {
+	case 5:
+		return "sesame_5"
+	case 7:
+		return "sesame_5_pro"
+	case 20:
+		return "sesame_6"
+	case 21:
+		return "sesame_6_pro"
+	}
+	return ""
+}
+func Supported(s string) bool {
+	for _, p := range []byte{5, 7, 20, 21} {
+		if model(p) == s {
+			return true
+		}
+	}
+	return false
+}
+func Advertisement(b []byte) (Target, bool) {
+	if len(b) < 19 || model(b[0]) == "" {
+		return Target{}, false
+	}
+	return Target{UUID: formatUUID(b[3:19]), Model: model(b[0]), Registered: b[2]&1 != 0}, true
+}
+
+type State struct {
+	Online          bool      `json:"online"`
+	Error           string    `json:"error,omitempty"`
+	Position        *int16    `json:"position,omitempty"`
+	Target          *int16    `json:"target,omitempty"`
+	Locked          bool      `json:"locked"`
+	Stopped         bool      `json:"stopped"`
+	Critical        bool      `json:"critical"`
+	BatteryCritical bool      `json:"battery_critical"`
+	LockPosition    *int16    `json:"lock_position,omitempty"`
+	UnlockPosition  *int16    `json:"unlock_position,omitempty"`
+	Boundary        *int16    `json:"boundary,omitempty"`
+	Updated         time.Time `json:"updated"`
+}
+
+func i16(b []byte) *int16 { v := int16(binary.LittleEndian.Uint16(b)); return &v }
+func short(v int16) []byte {
+	b := make([]byte, 2)
+	binary.LittleEndian.PutUint16(b, uint16(v))
+	return b
+}
+func (s *State) publish(item byte, b []byte) error {
+	switch item {
+	case 81:
+		if len(b) < 7 {
+			return errors.New("short mechanical status")
+		}
+		s.Position = i16(b[4:6])
+		s.Target = i16(b[2:4])
+		if *s.Target == -32768 {
+			s.Target = nil
+		}
+		s.Locked = b[6]&2 != 0
+		s.Critical = b[6]&8 != 0
+		s.Stopped = b[6]&16 != 0
+		s.BatteryCritical = b[6]&32 != 0
+	case 80:
+		if len(b) < 6 {
+			return errors.New("short mechanical settings")
+		}
+		s.LockPosition = i16(b[:2])
+		s.UnlockPosition = i16(b[2:4])
+	case 20:
+		if len(b) != 2 {
+			return errors.New("invalid lock boundary")
+		}
+		s.Boundary = i16(b)
+	}
+	s.Updated = time.Now()
+	return nil
+}
+
+type assembler struct {
+	data   []byte
+	active bool
+}
+
+func (a *assembler) feed(b []byte) (byte, []byte, error) {
+	if len(b) < 2 || len(b) > 20 || b[0] > 5 {
+		return 0, nil, errors.New("invalid Sesame fragment")
+	}
+	start, kind := b[0]&1 != 0, b[0]>>1
+	if start {
+		if a.active {
+			return 0, nil, errors.New("interrupted Sesame message")
+		}
+		a.data = nil
+		a.active = true
+	} else if !a.active {
+		return 0, nil, errors.New("orphan Sesame fragment")
+	}
+	if len(a.data)+len(b)-1 > maxMessage {
+		return 0, nil, errors.New("Sesame message too large")
+	}
+	a.data = append(a.data, b[1:]...)
+	if kind == 0 {
+		return 0, nil, nil
+	}
+	out := a.data
+	a.data = nil
+	a.active = false
+	return kind, out, nil
+}
+func fragments(kind byte, b []byte) ([][]byte, error) {
+	if (kind != 1 && kind != 2) || len(b) == 0 || len(b) > maxMessage {
+		return nil, errors.New("invalid Sesame message")
+	}
+	var out [][]byte
+	start := byte(1)
+	for len(b) > 0 {
+		n := len(b)
+		if n > 19 {
+			n = 19
+		}
+		h := start
+		if n == len(b) {
+			h |= kind << 1
+		}
+		f := append([]byte{h}, b[:n]...)
+		out = append(out, f)
+		b = b[n:]
+		start = 0
+	}
+	return out, nil
+}
+
+type ResultError struct{ Item, Code byte }
+
+func (e *ResultError) Error() string {
+	return fmt.Sprintf("Sesame rejected command 0x%02x (result %d)", e.Item, e.Code)
+}
diff --git a/drivers/sesame/protocol_test.go b/drivers/sesame/protocol_test.go
new file mode 100644
index 0000000..5c097b4
--- /dev/null
+++ b/drivers/sesame/protocol_test.go
@@ -0,0 +1,150 @@
+package sesame
+
+import (
+	"bytes"
+	"crypto/aes"
+	"encoding/hex"
+	"testing"
+)
+
+func unhex(s string) []byte {
+	b, e := hex.DecodeString(s)
+	if e != nil {
+		panic(e)
+	}
+	return b
+}
+func TestCMACRFC4493(t *testing.T) {
+	key := unhex("2b7e151628aed2a6abf7158809cf4f3c")
+	for _, v := range [][2]string{{"", "bb1d6929e95937287fa37d129b756746"}, {"6bc1bee22e409f96e93d7e117393172a", "070a16b46b4d4144f79bdd9dd04a287c"}, {"6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411", "dfa66747de9ae63030ca32611497c827"}} {
+		got, e := cmac(key, unhex(v[0]))
+		if e != nil || hex.EncodeToString(got) != v[1] {
+			t.Fatalf("CMAC mismatch %x %v", got, e)
+		}
+	}
+}
+func TestCCMIndependentVectors(t *testing.T) {
+	// Generated independently with Python cryptography/OpenSSL AESCCM, 4-byte
+	// tag and AAD 00. Nonzero upper counter bytes catch the SDK's stale comment.
+	key := unhex("000102030405060708090a0b0c0d0e0f")
+	token := unhex("11223344")
+	c, auth, e := newCipher(key, token)
+	if e != nil || hex.EncodeToString(auth) != "1d7c7f1d52493a30cc2ee841f461afaf" {
+		t.Fatal("session CMAC", e)
+	}
+	vectors := map[int]string{0: "e9edea60", 1: "6d19848f18", 16: "6d872d6cad7752dde946535210729276a0c5f8de", 17: "6d872d6cad7752dde946535210729276b241e495c5", 40: "6d872d6cad7752dde946535210729276b20637e7243ebf8f473862b5b847d68743047366a43bfe0b98f3ce1f"}
+	for n, want := range vectors {
+		p := make([]byte, n)
+		for i := range p {
+			p[i] = byte(i)
+		}
+		c.tx = 0x0102030405060708
+		c.rx = c.tx
+		got, e := c.encrypt(p)
+		if e != nil || hex.EncodeToString(got) != want {
+			t.Fatalf("n=%d: %x %v", n, got, e)
+		}
+		plain, e := c.decrypt(got)
+		if e != nil || !bytes.Equal(p, plain) {
+			t.Fatalf("decrypt: %v", e)
+		}
+		c.rx--
+		got[len(got)-1] ^= 1
+		before := c.rx
+		if _, e = c.decrypt(got); e == nil || c.rx != before {
+			t.Fatal("accepted corrupt tag or advanced counter")
+		}
+	}
+	if _, _, e := newCipher(key, token[:3]); e == nil {
+		t.Fatal("accepted bad token")
+	}
+	b, _ := aes.NewCipher(key)
+	if _, e := ccmOpen(b, make([]byte, 13), []byte{1}); e == nil {
+		t.Fatal("accepted short CCM")
+	}
+}
+func TestFragmentBoundaries(t *testing.T) {
+	for _, n := range []int{1, 19, 20, 38, 39, 1024} {
+		p := make([]byte, n)
+		for i := range p {
+			p[i] = byte(i)
+		}
+		for _, kind := range []byte{1, 2} {
+			fs, e := fragments(kind, p)
+			if e != nil {
+				t.Fatal(e)
+			}
+			var a assembler
+			var out []byte
+			for i, f := range fs {
+				k, b, e := a.feed(f)
+				if e != nil {
+					t.Fatal(e)
+				}
+				if i < len(fs)-1 && k != 0 {
+					t.Fatal("early completion")
+				}
+				if i == len(fs)-1 {
+					if k != kind {
+						t.Fatal("wrong kind")
+					}
+					out = b
+				}
+			}
+			if !bytes.Equal(p, out) {
+				t.Fatal("reassembly")
+			}
+		}
+	}
+	for _, p := range [][]byte{nil, {1}, {6, 1}, {0, 1}} {
+		var a assembler
+		if _, _, e := a.feed(p); e == nil {
+			t.Fatalf("accepted malformed %x", p)
+		}
+	}
+	var a assembler
+	a.feed([]byte{1, 0})
+	if _, _, e := a.feed([]byte{3, 0}); e == nil {
+		t.Fatal("accepted interrupted message")
+	}
+	a = assembler{}
+	a.feed(append([]byte{1}, make([]byte, 19)...))
+	for i := 0; i < 52; i++ {
+		a.feed(append([]byte{0}, make([]byte, 19)...))
+	}
+	if _, _, e := a.feed(append([]byte{0}, make([]byte, 19)...)); e == nil {
+		t.Fatal("unbounded message")
+	}
+}
+func TestMechanicalStatusAndBoundary(t *testing.T) {
+	var s State
+	if e := s.publish(81, []byte{0, 0, 0, 128, 255, 255, 0x3a}); e != nil {
+		t.Fatal(e)
+	}
+	if *s.Position != -1 || s.Target != nil || !s.Locked || !s.Stopped || !s.Critical || !s.BatteryCritical {
+		t.Fatalf("bad status %+v", s)
+	}
+	if e := s.publish(20, []byte{255, 255}); e != nil || s.Boundary == nil || *s.Boundary != -1 {
+		t.Fatal("-1 is a valid boundary")
+	}
+	if e := s.publish(80, []byte{90, 0, 0, 0, 0, 0}); e != nil || *s.LockPosition != 90 || *s.UnlockPosition != 0 {
+		t.Fatal("settings")
+	}
+	if e := s.publish(81, []byte{1}); e == nil {
+		t.Fatal("short status")
+	}
+}
+func TestAdvertisement(t *testing.T) {
+	b := append([]byte{21, 0, 1}, unhex("00112233445566778899aabbccddeeff")...)
+	d, ok := Advertisement(b)
+	if !ok || d.UUID != "00112233-4455-6677-8899-aabbccddeeff" || d.Model != "sesame_6_pro" || !d.Registered {
+		t.Fatal(d)
+	}
+	b[0] = 1
+	if _, ok := Advertisement(b); ok {
+		t.Fatal("accepted OS2")
+	}
+	if _, ok := Advertisement([]byte{21}); ok {
+		t.Fatal("short advertisement")
+	}
+}
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index 55979e5..2723942 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -37,6 +37,44 @@ serving it after save but leaves its credential state on disk. Changes remain
 in the browser until Save configuration. Concurrent edits from another page
 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
+   `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.
+4. Open **Set positions**. With the door open, turn the thumb-turn to the desired
+   locked position, wait for the displayed position to update, and click
+   **Use current as locked**. Repeat for unlocked. Set the boundary the same way;
+   its button is enabled only when firmware reports support. Changes are saved
+   directly to the lock. Use **Test lock** / **Test unlock** to verify movement.
+5. Edit a Home Key lock and select its **Assigned physical locks**, alongside its
+   assigned readers. Save configuration. Several virtual locks can share one
+   physical lock; a tap dispatches at most one unlock per physical device.
+
+Physical-lock enrollment and calibration take effect immediately. Virtual-lock
+assignments remain drafts until **Save configuration**. No physical assignment
+means simulation mode. Home Key success means the lock accepted the unlock
+command; motor completion is reported separately from real state notifications.
+Offline, moving, critical or conflicting multi-lock states appear as Unknown.
+
+The controller uses direct BlueZ on the local adapter. The server must be in BLE
+range; HA Bluetooth proxies and ESP32 lock tunnelling are not included. Connections
+are kept open for prompt commands and manual state updates; battery impact and
+phone-app coexistence need testing on the actual lock.
+
+The physical registry is `/data/state/physical-locks.json`; private keys are under
+`/data/state/physical-lock-keys/` with restrictive permissions. Back up the entire
+state directory. Keys are excluded from public configuration/status responses.
+If enrollment saved a key but failed to save registry metadata, retry adding the
+same device to recover it. No reset or physical-lock key deletion is exposed.
+
 ## Updating from 0.0.6
 
 The first start imports existing locks, reader IDs, addresses, adapters and
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index cf28670..de5b566 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -221,6 +221,15 @@ func TestSharedReaderPerLockAuthorization(t *testing.T) {
 	if b.Device.Lock.LockCurrentState.Value() != 1 {
 		t.Fatal("revoked lock unlocked")
 	}
+	a.Config.PhysicalLocks = []string{"sesame-missing"}
+	a.Device.Lock.LockCurrentState.SetValue(1)
+	if n := unlockAuthorized(context.Background(), []*Lock{a}, snapshots, groupKey, result, "porch", 3); n != 0 {
+		t.Fatal("missing actuator controller reported success")
+	}
+	if a.Device.Lock.LockCurrentState.Value() != 1 {
+		t.Fatal("fabricated physical unlock state")
+	}
+	a.Config.PhysicalLocks = nil
 	ctx, cancel := context.WithCancel(context.Background())
 	cancel()
 	if n := unlockAuthorized(ctx, []*Lock{a}, snapshots, groupKey, result, "porch", 3); n != 0 {
diff --git a/internal/app/config.go b/internal/app/config.go
index f64a66a..a36ad4b 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -10,11 +10,12 @@ import (
 )
 
 type LockConfig struct {
-	ID      string   `json:"id"`
-	Name    string   `json:"name"`
-	Port    int      `json:"port"`
-	Finish  string   `json:"finish"`
-	Readers []string `json:"readers"`
+	ID            string   `json:"id"`
+	Name          string   `json:"name"`
+	Port          int      `json:"port"`
+	Finish        string   `json:"finish"`
+	Readers       []string `json:"readers"`
+	PhysicalLocks []string `json:"physical_locks,omitempty"`
 }
 type ReaderConfig struct {
 	ID      string `json:"id"`
@@ -80,6 +81,13 @@ func (c Config) Validate() error {
 		default:
 			return fmt.Errorf("invalid lock finish")
 		}
+		physical := map[string]bool{}
+		for _, id := range l.PhysicalLocks {
+			if !identifier.MatchString(id) || physical[id] {
+				return fmt.Errorf("invalid or duplicate physical lock %q", id)
+			}
+			physical[id] = true
+		}
 		seen := map[string]bool{}
 		for _, r := range l.Readers {
 			if !readers[r] || seen[r] {
@@ -116,6 +124,9 @@ func (c *Config) Normalize() {
 		if l.Readers == nil {
 			l.Readers = []string{}
 		}
+		for j := range l.PhysicalLocks {
+			l.PhysicalLocks[j] = strings.TrimSpace(l.PhysicalLocks[j])
+		}
 		for j := range l.Readers {
 			l.Readers[j] = strings.TrimSpace(l.Readers[j])
 		}
diff --git a/internal/app/page.html b/internal/app/page.html
index c98a4a1..f8780cc 100644
--- a/internal/app/page.html
+++ b/internal/app/page.html
@@ -1,38 +1,43 @@
 <!doctype html>
 <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home Key locks</title>
 <style>
+[hidden]{display:none!important}
 :root{font:16px system-ui;color:#172735;background:#f4f6f8}body{max-width:920px;margin:30px auto;padding:0 20px}h1{margin-bottom:8px}h2{margin-top:0}article,section{background:white;border:1px solid #ccd6df;border-radius:12px;padding:20px;margin:18px 0}button,input,select{font:inherit}button{padding:9px 16px;border:1px solid #bcc9d5;border-radius:6px;background:white;cursor:pointer}button.primary{background:#136fb1;color:white;border-color:#136fb1}button:disabled{opacity:.5;cursor:default}.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin:16px 0}.muted,small{color:#536675}.error{color:#a02020}label{display:block;margin:14px 0}input:not([type=checkbox]),select{box-sizing:border-box;display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #bcc9d5;border-radius:5px}input[type=checkbox]{margin-right:10px;accent-color:#136fb1}fieldset{border:1px solid #ccd6df;border-radius:6px}dialog{border:1px solid #bcc9d5;border-radius:12px;padding:24px;max-width:520px;width:calc(100% - 90px)}dialog::backdrop{background:#17273577}code{font-size:26px}.badge{font-size:14px;background:#eaf1f6;border-radius:4px;padding:4px 7px}.reader{display:flex;justify-content:space-between;gap:12px;align-items:center;border-top:1px solid #e0e7ed;padding:14px 0}.reader:first-child{border-top:0}.reader p{margin:4px 0}.empty{color:#536675}.notice{min-height:24px}details{margin:15px 0}
 </style></head><body>
-<h1>Home Key locks</h1><p class="muted">Choose your readers, assign them to locks, then pair with Apple Home.</p>
+<h1>Home Key locks</h1><p class="muted">Assign NFC readers and physical locks to a Home Key lock, then pair with Apple Home.</p>
 <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>
 <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><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="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="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);
 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 physicalLocks=[],physicalDiscovery={locks:[],adapters:[],warnings:[]},physicalScanning=false,physicalAdding=false,calibrating=null,calibrationBusy=false;
 const label=r=>r.name||r.id;
 function message(text,error=false){$('message').textContent=text;$('message').className='notice'+(error?' error':'')}
 async function request(path,options={}){const r=await fetch(apiBase+path,options);if(!r.ok)throw Error(await r.text());return r.status===204||r.status===202?null:r.json()}
 function markDirty(){dirty=true;$('save').disabled=false;message('Unsaved changes. Save configuration to apply them.');render()}
 function render(){
  const main=$('locks');main.replaceChildren();if(!config.locks.length)main.append(el('p','No locks yet. Add a lock to get started.','empty'));
- for(const l of config.locks){const a=el('article',''),s=status.find(x=>x.id===l.id);a.append(el('h2',l.name),el('small','ID: '+l.id+' · HomeKit port: '+l.port));const names=l.readers.map(id=>{const r=config.readers.find(r=>r.id===id);return r?label(r):id});a.append(el('p','Readers: '+(names.join(', ')||'None assigned')));const paired=!!s?.summary.paired_controllers;a.append(el('span',s?(paired?'Paired':'Unpaired'):'Pending save','badge'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openLock(l.id)),button('Remove',()=>{config.locks=config.locks.filter(x=>x.id!==l.id);markDirty()}));
+ for(const l of config.locks){const a=el('article',''),s=status.find(x=>x.id===l.id);a.append(el('h2',l.name),el('small','ID: '+l.id+' · HomeKit port: '+l.port));const names=l.readers.map(id=>{const r=config.readers.find(r=>r.id===id);return r?label(r):id});a.append(el('p','Readers: '+(names.join(', ')||'None assigned')));a.append(el('p','Physical locks: '+((l.physical_locks||[]).map(id=>physicalLocks.find(p=>p.id===id)?.name||id).join(', ')||'None (simulation)')));const paired=!!s?.summary.paired_controllers;a.append(el('span',s?(paired?'Paired':'Unpaired'):'Pending save','badge'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openLock(l.id)),button('Remove',()=>{config.locks=config.locks.filter(x=>x.id!==l.id);markDirty()}));
   if(s?.pin){a.append(el('p','Pairing code'),el('code',s.pin),el('p','Window ends '+new Date(s.until).toLocaleTimeString()));actions.append(button('Cancel pairing',()=>pair(l.id,'cancel')))}else if(s&&!paired){const b=button('Pair',()=>pair(l.id,'pair'));b.disabled=dirty||restarting;actions.append(b)}a.append(actions);main.append(a)}
  const readers=$('readers');readers.replaceChildren();if(!config.readers.length)readers.append(el('p','No readers configured. Click Add reader to choose a Bluetooth reader.','empty'));
  for(const r of config.readers){const row=el('div','','reader'),info=el('div','');info.append(el('strong',label(r)),el('p',r.address+' · '+r.adapter,'muted'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openReader(r.id)),button('Remove',()=>{config.readers=config.readers.filter(x=>x.id!==r.id);for(const l of config.locks)l.readers=l.readers.filter(id=>id!==r.id);markDirty()}));row.append(info,actions);readers.append(row)}
 }
 async function load(){const data=await request('api/config');config=data.config;revision=data.revision;dirty=false;$('save').disabled=true;$('interface').value=config.interface||'';await refresh()}
-async function refresh(){try{const data=await request('api/locks');status=data.locks;render();if(!dirty&&!restarting)message('');for(const r of data.readers){if(r.warning)message(label(r)+': '+r.warning,true)}}catch(e){if(!restarting)message(e.message,true)}}
+async function refresh(){try{const [data,physical]=await Promise.all([request('api/locks'),request('api/physical-locks')]);status=data.locks;physicalLocks=physical;render();renderPhysical();updateCalibration();if(!dirty&&!restarting)message('');for(const r of data.readers){if(r.warning)message(label(r)+': '+r.warning,true)}}catch(e){if(!restarting)message(e.message,true)}}
 async function pair(id,action){try{await request('api/locks/'+encodeURIComponent(id)+'/'+action,{method:'POST',headers:{'X-Homekey-Action':'pairing'}});await refresh()}catch(e){message(e.message,true)}}
-function openLock(id=null){editingLock=id;const l=config.locks.find(x=>x.id===id);$('lock-title').textContent=l?'Edit lock':'Add lock';$('lock-id').textContent=l?'Stable ID: '+l.id:'A stable ID will be generated automatically.';$('lock-name').value=l?.name||'';let port=51826;while(config.locks.some(x=>x.port===port))port++;$('lock-port').value=l?.port||port;$('lock-finish').value=l?.finish||'silver';$('lock-error').textContent='';const list=$('assignments');list.replaceChildren();if(!config.readers.length)list.append(el('p','No readers configured. Add a reader after creating this lock.'));for(const r of config.readers){const labelNode=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=r.id;input.checked=!!l?.readers.includes(r.id);labelNode.append(input,document.createTextNode(label(r)+' · '+r.address));list.append(labelNode)}$('lock-dialog').showModal()}
-$('lock-form').onsubmit=e=>{e.preventDefault();const name=$('lock-name').value.trim(),port=Number($('lock-port').value);if(!name){$('lock-error').textContent='Enter a lock name.';return}if(config.locks.some(x=>x.id!==editingLock&&x.port===port)||port===8099){$('lock-error').textContent='Choose an unused HomeKit port.';return}const id=editingLock||'lock-'+Array.from(crypto.getRandomValues(new Uint8Array(8)),x=>x.toString(16).padStart(2,'0')).join('');const l={id,name,port,finish:$('lock-finish').value,readers:[...$('assignments').querySelectorAll('input:checked')].map(x=>x.value)};if(editingLock)config.locks=config.locks.map(x=>x.id===editingLock?l:x);else config.locks.push(l);$('lock-dialog').close();markDirty()};
+function openLock(id=null){editingLock=id;const l=config.locks.find(x=>x.id===id);$('lock-title').textContent=l?'Edit lock':'Add lock';$('lock-id').textContent=l?'Stable ID: '+l.id:'A stable ID will be generated automatically.';$('lock-name').value=l?.name||'';let port=51826;while(config.locks.some(x=>x.port===port))port++;$('lock-port').value=l?.port||port;$('lock-finish').value=l?.finish||'silver';$('lock-error').textContent='';const list=$('assignments');list.replaceChildren();if(!config.readers.length)list.append(el('p','No readers configured. Add a reader after creating this lock.'));for(const r of config.readers){const labelNode=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=r.id;input.checked=!!l?.readers.includes(r.id);labelNode.append(input,document.createTextNode(label(r)+' · '+r.address));list.append(labelNode)}const physicalList=$('physical-assignments');physicalList.replaceChildren();if(!physicalLocks.length)physicalList.append(el('p','No physical locks added yet.'));for(const p of physicalLocks){const row=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=p.id;input.checked=!!l?.physical_locks?.includes(p.id);row.append(input,document.createTextNode(p.name+' · '+p.target.model));physicalList.append(row)}$('lock-dialog').showModal()}
+$('lock-form').onsubmit=e=>{e.preventDefault();const name=$('lock-name').value.trim(),port=Number($('lock-port').value);if(!name){$('lock-error').textContent='Enter a lock name.';return}if(config.locks.some(x=>x.id!==editingLock&&x.port===port)||port===8099){$('lock-error').textContent='Choose an unused HomeKit port.';return}const id=editingLock||'lock-'+Array.from(crypto.getRandomValues(new Uint8Array(8)),x=>x.toString(16).padStart(2,'0')).join('');const l={id,name,port,finish:$('lock-finish').value,readers:[...$('assignments').querySelectorAll('input:checked')].map(x=>x.value),physical_locks:[...$('physical-assignments').querySelectorAll('input:checked')].map(x=>x.value)};if(editingLock)config.locks=config.locks.map(x=>x.id===editingLock?l:x);else config.locks.push(l);$('lock-dialog').close();markDirty()};
 $('cancel-lock').onclick=()=>$('lock-dialog').close();$('add-lock').onclick=()=>openLock();
 function options(select,items,value){select.replaceChildren();for(const item of items){const o=el('option',item.label);o.value=item.value;select.append(o)}if(items.some(x=>x.value===value))select.value=value}
 function readerChoices(){const current=config.readers.find(r=>r.id===editingReader),devices=[...discovery.readers];if(current&&!devices.some(d=>d.address===current.address&&d.adapter===current.adapter))devices.push({...current,name:label(current)});const available=devices.filter(d=>!config.readers.some(r=>r.id!==editingReader&&r.address===d.address));const selected=$('reader-device').value;options($('reader-device'),[...available.map(d=>({value:d.adapter+'|'+d.address,label:d.name+' · '+d.address+' · '+d.adapter})),{value:'manual',label:'Enter address manually'}],selected|| (current?current.adapter+'|'+current.address:''));const adapters=discovery.adapters.map(a=>({value:a.id,label:(a.name||'Bluetooth adapter')+' · '+a.id+(a.powered?'':' (off)')}));for(const r of config.readers){if(!adapters.some(a=>a.value===r.adapter))adapters.push({value:r.adapter,label:r.adapter+' (configured)'})}if(!adapters.length)adapters.push({value:'hci0',label:'Default adapter (hci0) — scan to verify'});options($('reader-adapter'),adapters,$('reader-adapter').value||current?.adapter);selectDevice()}
@@ -44,5 +49,31 @@ $('add-reader').onclick=()=>openReader();$('cancel-reader').onclick=()=>$('reade
 async function scan(){if(scanning)return;scanning=true;$('scan').disabled=true;$('discovery-status').textContent='Scanning Bluetooth for Home Key readers…';try{discovery=await request('api/discovery');$('discovery-status').textContent=(discovery.readers.length?discovery.readers.length+' reader(s) found.':'No Home Key readers found. Power on your reader and scan again, or enter its address manually.')+' '+(discovery.warnings||[]).join(' · ');if($('reader-dialog').open)readerChoices()}catch(e){$('discovery-status').textContent=e.message+' You can still enter a reader address manually.'}finally{scanning=false;$('scan').disabled=false}}
 $('scan').onclick=scan;$('interface').oninput=()=>{config.interface=$('interface').value.trim();markDirty()};
 $('save').onclick=async()=>{if(restarting)return;restarting=true;$('save').disabled=true;message('Saving configuration and restarting connections…');try{await request('api/config',{method:'POST',headers:{'Content-Type':'application/json','X-Homekey-Action':'configuration'},body:JSON.stringify({config,revision})});for(let i=0;i<40;i++){await new Promise(resolve=>setTimeout(resolve,500));try{await load();restarting=false;message('Configuration saved.');return}catch{}}throw Error('Configuration saved; service is still restarting. Reload this page.')}catch(e){restarting=false;$('save').disabled=!dirty;message(e.message,true)}};
+
+function renderPhysical(){
+ const list=$('physical-locks');list.replaceChildren();if(!physicalLocks.length)list.append(el('p','No physical locks added.','empty'));
+ for(const p of physicalLocks){const row=el('div','','reader'),info=el('div',''),state=p.state;info.append(el('strong',p.name),el('p',p.target.model+' · '+p.target.adapter,'muted'));info.append(el('p',physicalState(state),state.online?'muted':'error'));const actions=el('div','','toolbar');actions.append(button('Set positions',()=>openCalibration(p.id)));row.append(info,actions);list.append(row)}
+}
+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}}
+$('scan-physical').onclick=scanPhysical;
+$('add-physical').onclick=()=>{$('physical-name').value='';$('physical-key').value='';$('physical-error').textContent='';physicalChoices();$('physical-dialog').showModal();scanPhysical()};
+$('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}}
+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;
+ $('capture-lock').disabled=$('capture-unlock').disabled=!ready||s.lock_position===undefined||s.unlock_position===undefined;$('capture-boundary').disabled=!ready||s.boundary===undefined;$('test-lock').disabled=$('test-unlock').disabled=!s.online||calibrationBusy;
+ $('boundary-help').textContent=s.boundary===undefined?'Boundary adjustment becomes available when the lock firmware reports support.':'The boundary determines where the lock changes between locked and unlocked status. Settings are saved directly to the lock.';
+}
+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);
 </script></body></html>
diff --git a/internal/app/pairing.go b/internal/app/pairing.go
index 55c4b86..7eceb52 100644
--- a/internal/app/pairing.go
+++ b/internal/app/pairing.go
@@ -6,6 +6,7 @@ import (
 	"github.com/brutella/hap"
 	"github.com/go-chi/chi"
 	"homekey.local/provisioner/internal/hkserver"
+	"homekey.local/provisioner/internal/physical"
 	"homekey.local/provisioner/internal/provision"
 	"math/big"
 	"net/http"
@@ -14,12 +15,13 @@ import (
 )
 
 type Lock struct {
-	Config  LockConfig
-	Store   *provision.Store
-	Device  *hkserver.Device
-	pairing sync.Mutex
-	until   time.Time
-	pin     string
+	Physical *physical.Manager
+	Config   LockConfig
+	Store    *provision.Store
+	Device   *hkserver.Device
+	pairing  sync.Mutex
+	until    time.Time
+	pin      string
 }
 
 func NewLock(c LockConfig, store *provision.Store, ifaces []string) (*Lock, error) {
diff --git a/internal/app/physical.go b/internal/app/physical.go
new file mode 100644
index 0000000..59e7a68
--- /dev/null
+++ b/internal/app/physical.go
@@ -0,0 +1,149 @@
+package app
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"time"
+
+	"homekey.local/provisioner/drivers/sesame"
+	"homekey.local/provisioner/internal/physical"
+)
+
+// BindPhysical installs command handlers before HAP starts. The returned worker
+// projects confirmed actuator state, including manual movement, into HAP.
+func BindPhysical(locks []*Lock, m *physical.Manager) func(context.Context) {
+	for _, l := range locks {
+		l.Physical = m
+		if len(l.Config.PhysicalLocks) == 0 {
+			continue
+		}
+		l.Device.Lock.LockCurrentState.SetValue(3) // Unknown until live status arrives.
+		l.Device.Lock.LockTargetState.OnSetRemoteValue(func(v int) error {
+			ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+			defer cancel()
+			return <-m.Dispatch(ctx, l.Config.PhysicalLocks, v == 1)
+		})
+	}
+	return func(ctx context.Context) {
+		timer := time.NewTicker(500 * time.Millisecond)
+		defer timer.Stop()
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-timer.C:
+				states := map[string]sesame.State{}
+				for _, v := range m.Views() {
+					states[v.ID] = v.State
+				}
+				for _, l := range locks {
+					if len(l.Config.PhysicalLocks) == 0 {
+						continue
+					}
+					state := aggregatePhysical(l.Config.PhysicalLocks, states)
+					previous := l.Device.Lock.LockCurrentState.Value()
+					l.Device.Lock.LockCurrentState.SetValue(state)
+					// Manual turns must also settle HomeKit's target. Do not
+					// overwrite an outstanding target on unchanged old status.
+					if state < 2 && state != previous {
+						l.Device.Lock.LockTargetState.SetValue(state)
+					}
+				}
+			}
+		}
+	}
+}
+func aggregatePhysical(ids []string, states map[string]sesame.State) int {
+	current := -1
+	for _, id := range ids {
+		s, ok := states[id]
+		if !ok || !s.Online || s.Position == nil || !s.Stopped || s.Critical {
+			return 3
+		}
+		v := 0
+		if s.Locked {
+			v = 1
+		}
+		if current != -1 && current != v {
+			return 3
+		}
+		current = v
+	}
+	if current < 0 {
+		return 3
+	}
+	return current
+}
+func physicalRoutes(mux *http.ServeMux, cfg *Settings) {
+	if cfg.Physical == nil {
+		return
+	}
+	mux.HandleFunc("GET /api/physical-locks", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(cfg.Physical.Views())
+	})
+	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()
+		d, e := sesame.Discover(ctx)
+		if e != nil {
+			http.Error(w, e.Error(), 503)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(d)
+	})
+	mux.HandleFunc("POST /api/physical-locks", func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("X-Homekey-Action") != "physical-lock" {
+			http.Error(w, "missing action header", 403)
+			return
+		}
+		var payload physical.Enrollment
+		r.Body = http.MaxBytesReader(w, r.Body, 8192)
+		d := json.NewDecoder(r.Body)
+		d.DisallowUnknownFields()
+		if e := d.Decode(&payload); e != nil {
+			http.Error(w, "invalid physical lock JSON", 400)
+			return
+		}
+		var extra any
+		if e := d.Decode(&extra); e != io.EOF {
+			http.Error(w, "trailing data", 400)
+			return
+		}
+		record, e := cfg.Physical.Add(r.Context(), payload)
+		if e != nil {
+			http.Error(w, e.Error(), 400)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+		json.NewEncoder(w).Encode(record)
+	})
+	mux.HandleFunc("POST /api/physical-locks/{id}/{action}", func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("X-Homekey-Action") != "physical-lock" {
+			http.Error(w, "missing action header", 403)
+			return
+		}
+		action := r.PathValue("action")
+		switch action {
+		case "lock", "unlock", "set-lock", "set-unlock", "set-boundary":
+		default:
+			http.NotFound(w, r)
+			return
+		}
+		if !cfg.Physical.Has(r.PathValue("id")) {
+			http.NotFound(w, r)
+			return
+		}
+		ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
+		defer cancel()
+		if e := cfg.Physical.Action(ctx, r.PathValue("id"), action); e != nil {
+			http.Error(w, e.Error(), 409)
+			return
+		}
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/internal/app/physical_test.go b/internal/app/physical_test.go
new file mode 100644
index 0000000..37d0744
--- /dev/null
+++ b/internal/app/physical_test.go
@@ -0,0 +1,75 @@
+package app
+
+import (
+	"context"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"homekey.local/provisioner/drivers/sesame"
+	"homekey.local/provisioner/internal/physical"
+)
+
+func TestPhysicalHTTPGuardsAndEmptyRegistry(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	m, e := physical.Open(ctx, t.TempDir(), func(context.Context, sesame.Target) (sesame.Link, sesame.Target, error) {
+		t.Fatal("unexpected Bluetooth action")
+		return nil, sesame.Target{}, nil
+	})
+	if e != nil {
+		t.Fatal(e)
+	}
+	defer m.Close()
+	s, e := OpenSettings(filepath.Join(t.TempDir(), "config.json"))
+	if e != nil {
+		t.Fatal(e)
+	}
+	s.Physical = m
+	h := Handler(nil, nil, false, s)
+	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}} {
+		r := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
+		r.Header.Set("X-Homekey-Action", tc.header)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+		if w.Code != tc.want {
+			t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String())
+		}
+	}
+	c, rev := s.Snapshot()
+	c.Locks = []LockConfig{{ID: "front", Name: "Front", Port: 51826, Finish: "silver", PhysicalLocks: []string{"sesame-missing"}}}
+	if e = s.Save(c, rev); e == nil {
+		t.Fatal("accepted unregistered assignment")
+	}
+}
+func TestAggregatePhysicalState(t *testing.T) {
+	p := int16(90)
+	good := sesame.State{Online: true, Position: &p, Stopped: true, Locked: true}
+	states := map[string]sesame.State{"a": good, "b": good}
+	if aggregatePhysical([]string{"a", "b"}, states) != 1 {
+		t.Fatal("locked")
+	}
+	good.Locked = false
+	states["b"] = good
+	if aggregatePhysical([]string{"a", "b"}, states) != 3 {
+		t.Fatal("mixed state must be unknown")
+	}
+	if aggregatePhysical([]string{"b"}, states) != 0 {
+		t.Fatal("unlocked")
+	}
+	good.Online = false
+	states["b"] = good
+	if aggregatePhysical([]string{"b"}, states) != 3 {
+		t.Fatal("offline state must be unknown")
+	}
+	good.Online = true
+	good.Stopped = false
+	states["b"] = good
+	if aggregatePhysical([]string{"b"}, states) != 3 {
+		t.Fatal("moving state must be unknown")
+	}
+}
diff --git a/internal/app/reader.go b/internal/app/reader.go
index 1f14123..617a70e 100644
--- a/internal/app/reader.go
+++ b/internal/app/reader.go
@@ -112,26 +112,63 @@ func Authenticate(ctx, serviceCtx context.Context, locks []*Lock, reader string,
 }
 
 func unlockAuthorized(ctx context.Context, locks []*Lock, snapshots map[*Lock]provision.Credentials, groupKey string, result *nfcauth.Result, reader string, session uint32) int {
+	// Share a result between virtual locks targeting the same actuator in this tap.
+	results := map[string]<-chan error{}
+	completed := map[string]error{}
 	unlocked := 0
 	for _, l := range locks {
 		expected := snapshots[l]
 		if expected.ReaderPrivateKey != groupKey {
 			continue
 		}
+		var physicalIDs []string
 		e := l.Store.WithAuthorization(expected, result.IssuerID, result.EndpointID, result.PublicKey, func() error {
 			if err := ctx.Err(); err != nil {
 				return err
 			}
-			if err := l.Device.Lock.LockTargetState.SetValue(0); err != nil {
-				return err
+			if len(l.Config.PhysicalLocks) == 0 {
+				if err := l.Device.Lock.LockTargetState.SetValue(0); err != nil {
+					return err
+				}
+				return l.Device.Lock.LockCurrentState.SetValue(0)
 			}
-			return l.Device.Lock.LockCurrentState.SetValue(0)
+			if l.Physical == nil {
+				return fmt.Errorf("physical lock controller unavailable")
+			}
+			for _, id := range l.Config.PhysicalLocks {
+				physicalIDs = append(physicalIDs, id)
+				if _, ok := results[id]; !ok {
+					results[id] = l.Physical.Dispatch(ctx, []string{id}, false)
+				}
+			}
+			return nil
 		})
 		if e != nil {
 			continue
 		}
+		for _, id := range physicalIDs {
+			err, ok := completed[id]
+			if !ok {
+				select {
+				case err = <-results[id]:
+				case <-ctx.Done():
+					err = ctx.Err()
+				}
+				completed[id] = err
+			}
+			if err != nil {
+				e = err
+			}
+		}
+		if e != nil {
+			log.Printf("Home Key physical unlock failed lock=%s: %v", l.Config.ID, e)
+			continue
+		}
+		if len(physicalIDs) > 0 {
+			_ = l.Device.Lock.LockTargetState.SetValue(0)
+		}
 		unlocked++
-		log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s lock=%s; virtual lock unlocked", reader, session, result.EndpointID, l.Config.ID)
+		log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s lock=%s; unlock accepted", reader, session, result.EndpointID, l.Config.ID)
 	}
 	return unlocked
 }
diff --git a/internal/app/settings.go b/internal/app/settings.go
index b0d22a3..a055799 100644
--- a/internal/app/settings.go
+++ b/internal/app/settings.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"homekey.local/provisioner/internal/physical"
 	"os"
 	"path/filepath"
 	"sync"
@@ -15,6 +16,7 @@ var ErrConflict = errors.New("configuration changed; reload the page before savi
 var ErrReloading = errors.New("configuration is restarting; retry after the page reconnects")
 
 type Settings struct {
+	Physical   *physical.Manager
 	mu         sync.Mutex
 	path       string
 	config     Config
@@ -66,6 +68,13 @@ func (s *Settings) Save(c Config, expected string) error {
 	if err := c.Validate(); err != nil {
 		return err
 	}
+	for _, l := range c.Locks {
+		for _, id := range l.PhysicalLocks {
+			if s.Physical == nil || !s.Physical.Has(id) {
+				return fmt.Errorf("unknown physical lock %q", id)
+			}
+		}
+	}
 	if s.BeforeSave != nil {
 		if err := s.BeforeSave(c); err != nil {
 			return err
diff --git a/internal/app/settings_test.go b/internal/app/settings_test.go
index 1270a11..2f61bda 100644
--- a/internal/app/settings_test.go
+++ b/internal/app/settings_test.go
@@ -10,19 +10,26 @@ import (
 	"testing"
 )
 
-func TestSettingsImportNormalizeAndConflict(t *testing.T) {
+func TestSettingsNormalizeAndConflict(t *testing.T) {
 	dir := t.TempDir()
 	seed := filepath.Join(dir, "options.json")
 	path := filepath.Join(dir, "config.json")
 	os.WriteFile(seed, []byte(`{"interface":" ","locks":[{"id":"front ","name":"Front ","port":51826,"finish":"silver","readers":["TestReader01 "]}],"readers":[{"id":" TestReader01 ","address":"70:af:09:16:42:5a ","adapter":" hci0 "}]}`), 0600)
-	s, err := OpenSettings(path, seed, true)
+	s, err := OpenSettings(path, true)
 	if err != nil {
 		t.Fatal(err)
 	}
 	if _, err = os.Stat(path); !os.IsNotExist(err) {
 		t.Fatal("status imported settings to disk")
 	}
-	s, err = OpenSettings(path, seed)
+	raw, err := os.ReadFile(seed)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err = os.WriteFile(path, raw, 0600); err != nil {
+		t.Fatal(err)
+	}
+	s, err = OpenSettings(path)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -41,7 +48,7 @@ func TestSettingsImportNormalizeAndConflict(t *testing.T) {
 		t.Fatal("second save accepted during reload")
 	}
 	os.WriteFile(seed, []byte(`{"locks":[],"readers":[]}`), 0600)
-	reopened, err := OpenSettings(path, seed)
+	reopened, err := OpenSettings(path)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -57,7 +64,7 @@ func TestSettingsImportNormalizeAndConflict(t *testing.T) {
 func TestConfigurationHTTPValidation(t *testing.T) {
 	path := filepath.Join(t.TempDir(), "config.json")
 	writeConfig(path, Config{Locks: []LockConfig{}, Readers: []ReaderConfig{}})
-	s, _ := OpenSettings(path, "")
+	s, _ := OpenSettings(path)
 	calls := 0
 	s.Reload = func() { calls++ }
 	handler := Handler(nil, nil, false, s)
diff --git a/internal/app/web.go b/internal/app/web.go
index 5d0a6a7..d6c8b96 100644
--- a/internal/app/web.go
+++ b/internal/app/web.go
@@ -19,6 +19,7 @@ func Handler(locks []*Lock, readers []ReaderConfig, ingress bool, settings ...*S
 	mux := http.NewServeMux()
 	if len(settings) > 0 && settings[0] != nil {
 		cfg := settings[0]
+		physicalRoutes(mux, cfg)
 		mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
 			c, rev := cfg.Snapshot()
 			w.Header().Set("Content-Type", "application/json")
diff --git a/internal/physical/manager.go b/internal/physical/manager.go
new file mode 100644
index 0000000..d4f2f06
--- /dev/null
+++ b/internal/physical/manager.go
@@ -0,0 +1,303 @@
+// SPDX-License-Identifier: Apache-2.0
+// Package physical owns enrolled actuator identities and their private keys.
+package physical
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"homekey.local/provisioner/drivers/sesame"
+)
+
+type Record struct {
+	ID     string        `json:"id"`
+	Name   string        `json:"name"`
+	Driver string        `json:"driver"`
+	Target sesame.Target `json:"target"`
+}
+type View struct {
+	Record
+	State sesame.State `json:"state"`
+}
+type Enrollment struct {
+	Name   string        `json:"name"`
+	Target sesame.Target `json:"target"`
+	Mode   string        `json:"mode"`
+	// Accept the SDK's toKeyJson fields, bound to the scanned device identity.
+	Key *struct {
+		UUID   string `json:"deviceUUID"`
+		Model  string `json:"deviceModel"`
+		Secret string `json:"secretKey"`
+		Index  string `json:"keyIndex"`
+		Public string `json:"sesame2PublicKey"`
+	} `json:"key,omitempty"`
+}
+type Manager struct {
+	ctx     context.Context
+	cancel  context.CancelFunc
+	dir     string
+	dial    sesame.Dialer
+	enroll  sync.Mutex
+	mu      sync.RWMutex
+	records map[string]Record
+	clients map[string]*sesame.Client
+	wg      sync.WaitGroup
+}
+
+func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error) {
+	if dial == nil {
+		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{}}
+	if e := os.MkdirAll(filepath.Join(dir, "physical-lock-keys"), 0700); e != nil {
+		cancel()
+		return nil, e
+	}
+	raw, e := os.ReadFile(filepath.Join(dir, "physical-locks.json"))
+	if e != nil && !errors.Is(e, os.ErrNotExist) {
+		cancel()
+		return nil, e
+	}
+	var records []Record
+	if len(raw) > 0 {
+		if e = json.Unmarshal(raw, &records); e != nil {
+			cancel()
+			return nil, e
+		}
+	}
+	// Validate the whole registry before starting any device connections.
+	keys := map[string]sesame.Credential{}
+	for _, r := range records {
+		id, err := identity(r.Target)
+		if err != nil || id != r.ID || r.Driver != "sesame" || m.records[r.ID].ID != "" {
+			cancel()
+			return nil, errors.New("invalid physical lock registry")
+		}
+		c, e := m.readKey(id)
+		if e != nil {
+			cancel()
+			return nil, fmt.Errorf("physical lock %s: %w", id, e)
+		}
+		keys[id] = c
+		m.records[id] = r
+	}
+	for _, r := range records {
+		m.start(r, keys[r.ID])
+	}
+	return m, nil
+}
+func identity(t sesame.Target) (string, error) {
+	if e := sesame.ValidateTarget(t); e != nil {
+		return "", e
+	}
+	uuid, e := sesame.NormalizeUUID(t.UUID)
+	if e != nil {
+		return "", e
+	}
+	return "sesame-" + uuid, nil
+}
+func (m *Manager) keyPath(id string) string {
+	return filepath.Join(m.dir, "physical-lock-keys", id+".json")
+}
+func (m *Manager) readKey(id string) (sesame.Credential, error) {
+	var c sesame.Credential
+	b, e := os.ReadFile(m.keyPath(id))
+	if e != nil {
+		return c, e
+	}
+	if e = json.Unmarshal(b, &c); e != nil {
+		return c, e
+	}
+	return c, c.Validate()
+}
+func writeJSON(path string, v any) error {
+	b, e := json.MarshalIndent(v, "", "  ")
+	if e != nil {
+		return e
+	}
+	f, e := os.CreateTemp(filepath.Dir(path), ".physical-*")
+	if e != nil {
+		return e
+	}
+	defer os.Remove(f.Name())
+	if _, e = f.Write(append(b, '\n')); e != nil {
+		f.Close()
+		return e
+	}
+	if e = f.Sync(); e != nil {
+		f.Close()
+		return e
+	}
+	if e = f.Close(); e != nil {
+		return e
+	}
+	if e = os.Rename(f.Name(), path); e != nil {
+		return e
+	}
+	d, e := os.Open(filepath.Dir(path))
+	if e != nil {
+		return e
+	}
+	defer d.Close()
+	return d.Sync()
+}
+func (m *Manager) start(r Record, key sesame.Credential) {
+	c := sesame.NewClient(r.Target, key, m.dial)
+	m.clients[r.ID] = c
+	m.wg.Add(1)
+	go func() { defer m.wg.Done(); c.Run(m.ctx) }()
+}
+func (m *Manager) Close() { m.cancel(); m.enroll.Lock(); defer m.enroll.Unlock(); m.wg.Wait() }
+func (m *Manager) Has(id string) bool {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	_, ok := m.records[id]
+	return ok
+}
+func (m *Manager) Views() []View {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	out := make([]View, 0, len(m.records))
+	for id, r := range m.records {
+		out = append(out, View{r, m.clients[id].State()})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+	return out
+}
+func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) {
+	m.enroll.Lock()
+	defer m.enroll.Unlock()
+	name := strings.TrimSpace(in.Name)
+	if len(name) == 0 || len(name) > 128 {
+		return Record{}, errors.New("physical lock name is required (maximum 128 bytes)")
+	}
+	uuid, e := sesame.NormalizeUUID(in.Target.UUID)
+	if e != nil {
+		return Record{}, e
+	}
+	in.Target.UUID = uuid
+	id, e := identity(in.Target)
+	if e != nil {
+		return Record{}, e
+	}
+	if m.Has(id) {
+		return Record{}, errors.New("physical lock is already added")
+	}
+	if in.Mode != "register" && in.Mode != "import" {
+		return Record{}, errors.New("choose register or import")
+	}
+	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	defer cancel()
+	stop := context.AfterFunc(m.ctx, cancel)
+	defer stop()
+	// Check writable private storage before starting irreversible registration.
+	probe, e := os.CreateTemp(filepath.Join(m.dir, "physical-lock-keys"), ".probe-*")
+	if e != nil {
+		return Record{}, e
+	}
+	probe.Close()
+	os.Remove(probe.Name())
+	var key sesame.Credential
+	if recovered, err := m.readKey(id); err == nil {
+		// Retry can recover a key persisted before a failed registry write.
+		key = recovered
+		e = sesame.Probe(ctx, in.Target, key, m.dial)
+	} else if !errors.Is(err, os.ErrNotExist) {
+		return Record{}, err
+	} else if in.Mode == "import" {
+		if in.Key == nil {
+			return Record{}, errors.New("paste the Sesame key JSON")
+		}
+		keyUUID, err := sesame.NormalizeUUID(in.Key.UUID)
+		if err != nil || keyUUID != uuid || in.Key.Model != in.Target.Model {
+			return Record{}, errors.New("imported key does not match the selected lock")
+		}
+		key = sesame.Credential{Secret: strings.TrimSpace(in.Key.Secret)}
+		if e = key.Validate(); e != nil {
+			return Record{}, e
+		}
+		e = sesame.Probe(ctx, in.Target, key, m.dial)
+		if e == nil {
+			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) })
+	}
+	if e != nil {
+		return Record{}, e
+	}
+	in.Target.Registered = true
+	r := Record{ID: id, Name: name, Driver: "sesame", Target: in.Target}
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	records := make([]Record, 0, len(m.records)+1)
+	for _, v := range m.records {
+		records = append(records, v)
+	}
+	records = append(records, r)
+	if e = writeJSON(filepath.Join(m.dir, "physical-locks.json"), records); e != nil {
+		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)
+	return r, nil
+}
+func (m *Manager) Action(ctx context.Context, id, name string) error {
+	m.mu.RLock()
+	c := m.clients[id]
+	m.mu.RUnlock()
+	if c == nil {
+		return errors.New("unknown physical lock")
+	}
+	return c.Action(ctx, name)
+}
+
+// Dispatch accepts an authorized command without holding the provision store's
+// mutex through BLE I/O. Cancellation and a short deadline apply to the work.
+func (m *Manager) Dispatch(ctx context.Context, ids []string, locked bool) <-chan error {
+	result := make(chan error, 1)
+	copyIDs := append([]string(nil), ids...)
+	go func() {
+		ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
+		defer cancel()
+		stop := context.AfterFunc(m.ctx, cancel)
+		defer stop()
+		name := "unlock"
+		if locked {
+			name = "lock"
+		}
+		var wg sync.WaitGroup
+		errs := make(chan error, len(copyIDs))
+		seen := map[string]bool{}
+		for _, id := range copyIDs {
+			if seen[id] {
+				continue
+			}
+			seen[id] = true
+			wg.Add(1)
+			go func(id string) {
+				defer wg.Done()
+				if e := m.Action(ctx, id, name); e != nil {
+					errs <- fmt.Errorf("%s: %w", id, e)
+				}
+			}(id)
+		}
+		wg.Wait()
+		close(errs)
+		var all []error
+		for e := range errs {
+			all = append(all, e)
+		}
+		result <- errors.Join(all...)
+	}()
+	return result
+}
diff --git a/internal/physical/manager_test.go b/internal/physical/manager_test.go
new file mode 100644
index 0000000..8a098ac
--- /dev/null
+++ b/internal/physical/manager_test.go
@@ -0,0 +1,154 @@
+package physical
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"homekey.local/provisioner/drivers/sesame"
+)
+
+func TestPrivatePersistenceAndPublicViews(t *testing.T) {
+	dir := t.TempDir()
+	target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Registered: true}
+	id, _ := identity(target)
+	record := Record{id, "Front door", "sesame", target}
+	secret := "000102030405060708090a0b0c0d0e0f"
+	os.Mkdir(filepath.Join(dir, "physical-lock-keys"), 0700)
+	if e := writeJSON(filepath.Join(dir, "physical-lock-keys", id+".json"), sesame.Credential{Secret: secret}); e != nil {
+		t.Fatal(e)
+	}
+	if e := writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{record}); e != nil {
+		t.Fatal(e)
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+	m, e := Open(ctx, dir, func(context.Context, sesame.Target) (sesame.Link, sesame.Target, error) {
+		t.Error("dial after cancellation")
+		return nil, sesame.Target{}, ctx.Err()
+	})
+	if e != nil {
+		t.Fatal(e)
+	}
+	defer m.Close()
+	b, _ := json.Marshal(m.Views())
+	if strings.Contains(string(b), secret) || strings.Contains(string(b), "secret") {
+		t.Fatal("public view leaks credentials")
+	}
+	if !m.Has(id) || m.Has("missing") {
+		t.Fatal("registry lookup")
+	}
+	k, e := m.readKey(id)
+	if e != nil || k.Secret != secret {
+		t.Fatal("key not persisted")
+	}
+	for _, path := range []string{m.keyPath(id), filepath.Join(dir, "physical-locks.json")} {
+		st, e := os.Stat(path)
+		if e != nil || st.Mode().Perm() != 0600 {
+			t.Fatal("unsafe file permissions", path)
+		}
+	}
+	var records []Record
+	b, _ = os.ReadFile(filepath.Join(dir, "physical-locks.json"))
+	if e = json.Unmarshal(b, &records); e != nil || len(records) != 1 {
+		t.Fatal("registry")
+	}
+}
+func TestEnrollmentValidationBeforeBLE(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	m, e := Open(ctx, t.TempDir(), func(context.Context, sesame.Target) (sesame.Link, sesame.Target, error) {
+		t.Fatal("invalid input reached Bluetooth")
+		return nil, sesame.Target{}, nil
+	})
+	if e != nil {
+		t.Fatal(e)
+	}
+	defer m.Close()
+	target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0"}
+	for _, in := range []Enrollment{{Name: "", Target: target, Mode: "register"}, {Name: "Door", Target: target, Mode: "reset"}, {Name: "Door", Target: target, Mode: "import"}, {Name: "Door", Target: sesame.Target{UUID: "../../secret", Model: "sesame_6_pro", Adapter: "hci0"}, Mode: "register"}} {
+		if _, e = m.Add(ctx, in); e == nil {
+			t.Fatal("accepted invalid enrollment")
+		}
+	}
+}
+func TestRegistryRejectsTraversalAndMissingKey(t *testing.T) {
+	for _, id := range []string{"../../secret", "sesame-00112233-4455-6677-8899-aabbccddeeff"} {
+		dir := t.TempDir()
+		writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{{ID: id, Name: "Door", Driver: "sesame", Target: sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0"}}})
+		m, e := Open(context.Background(), dir, nil)
+		if e == nil {
+			m.Close()
+			t.Fatal("invalid registry accepted")
+		}
+	}
+}
+
+// The encrypted login response is an independent AESCCM fixture for the
+// synthetic key/token in this test, not generated by the driver under test.
+type importLink struct {
+	packets chan []byte
+	done    chan error
+	sent    bool
+}
+
+func newImportLink() *importLink {
+	l := &importLink{packets: make(chan []byte, 4), done: make(chan error)}
+	l.packets <- []byte{3, 8, 14, 0x11, 0x22, 0x33, 0x44}
+	return l
+}
+func (l *importLink) Packets() <-chan []byte { return l.packets }
+func (l *importLink) Done() <-chan error     { return l.done }
+func (l *importLink) Close()                 {}
+func (l *importLink) Write(ctx context.Context, b []byte) error {
+	if !l.sent {
+		l.sent = true
+		l.packets <- []byte{5, 0xbe, 0x9d, 0x51, 0x39, 0x09, 0xb8, 0x0b, 0x9e, 0x70, 0x6a, 0x5e}
+	}
+	return nil
+}
+func TestImportPersistsAndRestoresWithoutPublicSecret(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	dir := t.TempDir()
+	dial := func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
+		target.Registered = true
+		return newImportLink(), target, nil
+	}
+	m, e := Open(ctx, dir, dial)
+	if e != nil {
+		t.Fatal(e)
+	}
+	var in Enrollment
+	raw := `{"name":"Door","mode":"import","target":{"uuid":"00112233-4455-6677-8899-aabbccddeeff","model":"sesame_6_pro","adapter":"hci0"},"key":{"deviceUUID":"00112233-4455-6677-8899-aabbccddeeff","deviceModel":"sesame_6_pro","secretKey":"000102030405060708090a0b0c0d0e0f"}}`
+	if e = json.Unmarshal([]byte(raw), &in); e != nil {
+		t.Fatal(e)
+	}
+	record, e := m.Add(ctx, in)
+	if e != nil {
+		m.Close()
+		t.Fatal(e)
+	}
+	if !m.Has(record.ID) {
+		t.Fatal("import missing")
+	}
+	if _, e = m.Add(ctx, in); e == nil {
+		t.Fatal("duplicate import")
+	}
+	public, _ := json.Marshal(m.Views())
+	if strings.Contains(string(public), in.Key.Secret) {
+		t.Fatal("key leaked")
+	}
+	m.Close()
+	m, e = Open(ctx, dir, dial)
+	if e != nil {
+		t.Fatal(e)
+	}
+	defer m.Close()
+	if !m.Has(record.ID) {
+		t.Fatal("import not restored")
+	}
+}
diff --git a/scripts/stage-ha-app.sh b/scripts/stage-ha-app.sh
index 6352eec..9cf7a8f 100755
--- a/scripts/stage-ha-app.sh
+++ b/scripts/stage-ha-app.sh
@@ -20,7 +20,7 @@ mkdir -p "$dest"
 cp "$root/Dockerfile" "$root/.dockerignore" "$root/go.mod" "$root/go.sum" \
     "$root/LICENSE" "$root/NOTICE" "$dest/"
 
-cp -R "$root/cmd" "$root/internal" "$dest/"
+cp -R "$root/cmd" "$root/internal" "$root/drivers" "$dest/"
 
 mkdir -p "$dest/ha-app"