penguin/homekey-go

homekey-go

commit a759f74d210bc25e5f6c646de8892a2a10c4d055

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-05T10:19:31Z
subjectSupport adding multiple locks
commit a759f74d210bc25e5f6c646de8892a2a10c4d055
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-05T10:19:31Z

    Support adding multiple locks
---
 README.md                   | 108 +++++++++-----------
 build.env                   |   2 +-
 cmd/homekey/main.go         | 235 ++++++++++++++++++--------------------------
 config.example.json         |  19 ++++
 ha-app/CHANGELOG.md         |   9 ++
 ha-app/DOCS.md              | 222 +++++++++++++++--------------------------
 ha-app/NFC.md               |  15 +--
 ha-app/README.md            |   9 +-
 ha-app/config.yaml          |  37 ++++---
 ha-app/run.sh               |  40 +-------
 ha-app/translations/en.yaml |  29 ++----
 internal/app/app_test.go    | 229 ++++++++++++++++++++++++++++++++++++++++++
 internal/app/config.go      |  92 +++++++++++++++++
 internal/app/pairing.go     | 104 ++++++++++++++++++++
 internal/app/reader.go      | 137 ++++++++++++++++++++++++++
 internal/app/web.go         |  92 +++++++++++++++++
 16 files changed, 937 insertions(+), 442 deletions(-)

diff --git a/README.md b/README.md
index 02c4b96..7f60849 100644
--- a/README.md
+++ b/README.md
@@ -2,76 +2,53 @@
 
 Basically [kormax/apple-home-key-reader](https://github.com/kormax/apple-home-key-reader) in go.
 
-## Quick start on macOS
+## Configuration and pairing
+
+Build with Go 1.23+ using `make`. Copy [config.example.json](config.example.json)
+to `config.json`, edit the locks/readers, then run:
 
 ```sh
-brew install go
-make
-./bin/homekey -name "Test Lock"
+./bin/homekey -config config.json -state ./state
 ```
 
-Go 1.23 or newer is required. The first build downloads the pinned Go modules.
-The executable prints a generated pairing code, for example `XXXX-XXXX`.
-It saves that code and its accessory identity for later runs.
-
-1. Put your iPhone and the computer running this program on the same LAN.
-2. Open **Home → + → Add Accessory → More Options** (wording can vary by iOS).
-3. Select **Penguin Door** and enter the printed pairing code.
-4. Accept the uncertified-accessory prompt if displayed and complete setup.
-5. Look for the Home Key setup/Express Mode flow and the resulting key in Wallet.
-
-Use a compatible iPhone with a passcode and Apple Home/iCloud configured.
-If setup creates only a normal lock, check the provisioning log and status
-below; pairing alone does not prove that Wallet provisioned a key.
+Open http://127.0.0.1:8099 and click **Pair** for a lock. In Apple Home use
+**Add Accessory → More Options**, select its name and enter the code. The
+five-minute pairing window closes when paired, canceled, expired, or restarted.
+The code is generated in memory for each service run, never logged or saved,
+and hidden after pairing. Reopening a window during the same run uses the same
+code. Existing pairings use their stored keys and need no setup PIN.
 
-This accessory simulates locked/unlocked state for the Home app. A matching
-ESP32/PN532 BLE reader can authenticate an enrolled Home Key and unlock this
-virtual state. Changing it **does not operate a physical lock**. See
-[the NFC guide](ha-app/NFC.md) for deployment and first-tap testing.
+In HA OS, edit the app **Configuration** tab and restart, then use **Open Web UI**
+for pairing. See [HA app instructions](ha-app/DOCS.md).
 
-## Linux
-
-Install Go 1.23+ through your usual package manager or the Go distribution:
-
-```sh
-make
-./bin/homekey -name "Penguin Door" -interface eth0
-```
+Every lock has an immutable `id`, editable `name`, unique HomeKit TCP `port`,
+Wallet artwork `finish`, and a list of reader IDs. IDs allow letters, digits,
+underscores and hyphens (1–64 characters); they also supply stable serials.
+Keep an ID unchanged once paired. Each lock is a standalone HomeKit accessory
+with its own identity, pairing secrets and Home Key credentials.
 
-No Avahi daemon is required; the process advertises mDNS itself. For the first
-iPhone test, run directly on a machine on the phone's LAN. Containers and
-Kubernetes need additional multicast/network configuration; a published TCP
-port alone does not provide HomeKit discovery. Linux host networking is a
-possible container setup; host networking on Docker Desktop is not equivalent
-to a native macOS process for discovery.
+Readers have an `id`, ESP32 BLE `address`, and BlueZ `adapter` such as `hci0`.
+One reader can be assigned to several locks, and a lock can use several readers.
+Only assigned locks with an active enrollment for the authenticated endpoint
+unlock. Shared readers currently require a common Home Key group (normally
+locks in the same Apple Home); different groups disable ECP and reject taps.
+This firmware advertises one group. Multi-group firmware support is separate work.
 
-## Options
+The key artwork may be determined by the first Home Key lock in a Home;
+editing a finish may not change existing Wallet artwork.
 
 ```text
--name       Name in Home (default: Go Home Key)
--serial     Stable serial (default: GO-HOMEKEY-001)
+-config     Lock/reader configuration JSON (default: ./config.json)
 -state      Persistent directory (default: ./state)
--pin        Eight-digit PIN, optionally with hyphens; generated if omitted
--addr       HAP TCP listen address (default: :51826)
--interface  LAN interface for mDNS; empty uses eligible interfaces
--finish     silver, black, gold, or tan (default: silver)
--status     Print credential counts and exit; safe while the server is running
-```
-
-The key artwork color may be determined by the first Home Key lock in the
-home, so changing `-finish` later may not change the existing Wallet artwork.
-
-On macOS an explicit interface might be `en0` or `en1`:
-
-```sh
-./bin/homekey -name "Penguin Door" -interface en0 -state ./state
+-web-addr   Pairing UI address (default: 127.0.0.1:8099)
+-ingress    Restrict UI access to the HA ingress gateway
+-status     Print credential counts per configured lock and exit
 ```
 
-Choose your actual LAN interface. Allow the executable through the firewall:
-TCP 51826 (or the port you select), and local mDNS UDP 5353. Keep this service
-on the LAN; HomeKit protocol traffic should not be put behind an HTTP reverse
-proxy or published as an Internet service. Discovery usually does not cross
-VLANs without an mDNS reflector and suitable routing.
+Accessory-specific CLI flags have been removed. LAN interface selection is
+in the config JSON. HomeKit still needs TCP access to each lock's port and
+local mDNS UDP 5353. On macOS use the native process for discovery; BLE uses
+Linux BlueZ. The virtual states do not operate a physical lock.
 
 ## What successful provisioning looks like
 
@@ -84,13 +61,13 @@ Provisioning exchange complete: reader=true issuers=1 endpoints=1
 You can inspect the current counts without displaying any keys:
 
 ```sh
-./bin/homekey -status -state ./state
+./bin/homekey -config config.json -status -state ./state
 ```
 
 Example:
 
 ```json
-{"paired_controllers":1,"reader_provisioned":true,"issuers":1,"endpoints":1}
+{"front-door":{"paired_controllers":1,"reader_provisioned":true,"issuers":1,"endpoints":1}}
 ```
 
 - `paired_controllers > 0`: HomeKit pairing completed.
@@ -100,7 +77,7 @@ Example:
 
 ## Persistence and lifecycle
 
-`state/state.json` contains **both HAP pairing secrets and Home Key credentials**.
+`state/locks/<id>/state.json` contains **both HAP pairing secrets and Home Key credentials**.
 Keep the whole directory across restarts and back it up securely. Directory
 permissions are 0700 and state files are 0600. Writes use a synced temporary
 file plus atomic rename; directory syncing is best effort. A process lock
@@ -118,12 +95,15 @@ operation without updating live state. Corrupt saved state stops startup
 rather than silently losing the pairing.
 
 No private keys, credential TLVs or endpoint public keys are logged. The
-printed setup PIN is intentionally visible in the startup output. Do not
+setup PIN is only displayed during a pairing window in the Web UI. Do not
 turn on the HAP library's raw debug logging when using real credentials.
 
-To start over, first remove the accessory in Apple Home, stop the process,
-then **move the complete state directory to a backup location** and restart.
-That creates a new accessory identity. Do not delete state merely to restart.
+For upgrades from the single-lock version, first configure exactly one lock
+with its original serial as `id`, its original name, finish, and HAP port.
+The service binds the existing root `state.json` to this ID using a durable
+`legacy-lock-id` marker and keeps using that store. Add more locks after the
+first upgraded start. The old stored setup PIN is removed; identities and
+credentials remain. Do not delete state to upgrade or restart.
 
 ## Implemented protocol surface
 
diff --git a/build.env b/build.env
index 4c09e9b..d1411a9 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.5
+IMAGE_TAG=0.0.6
 
 APT_PROXY=
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
index adbb438..605f61d 100644
--- a/cmd/homekey/main.go
+++ b/cmd/homekey/main.go
@@ -3,27 +3,22 @@ package main
 
 import (
 	"context"
-	"crypto/rand"
 	"encoding/json"
 	"errors"
 	"flag"
 	"fmt"
+	"homekey.local/provisioner/internal/app"
 	"homekey.local/provisioner/internal/blegateway"
-	"homekey.local/provisioner/internal/nfcauth"
+	"homekey.local/provisioner/internal/provision"
 	"log"
-	"math/big"
 	"net"
 	"net/http"
 	"os"
 	"os/signal"
 	"path/filepath"
-	"strings"
+	"sync"
 	"syscall"
 	"time"
-
-	"github.com/brutella/hap"
-	"homekey.local/provisioner/internal/hkserver"
-	"homekey.local/provisioner/internal/provision"
 )
 
 func main() {
@@ -33,164 +28,122 @@ func main() {
 	}
 }
 func run() error {
-	name := flag.String("name", "Go Home Key", "name shown in Apple Home")
-	serial := flag.String("serial", "GO-HOMEKEY-001", "stable accessory serial number")
-	state := flag.String("state", "./state", "persistent state directory; keep across restarts")
-	pin := flag.String("pin", "", "8-digit pairing PIN (generated and saved if omitted)")
-	addr := flag.String("addr", ":51826", "HAP listen address")
-	iface := flag.String("interface", "", "LAN interface for mDNS; empty selects eligible interfaces")
-	finish := flag.String("finish", "silver", "Wallet artwork: silver, black, gold, tan")
-	status := flag.Bool("status", false, "print credential counts without starting the server")
-	bleReader := flag.String("ble-reader", "", "ESP32 Bluetooth MAC; empty disables NFC gateway")
-	bleAdapter := flag.String("ble-adapter", "hci0", "BlueZ adapter name")
-	bleInterval := flag.Duration("ble-ping-interval", 10*time.Second, "BLE PING interval")
+	state := flag.String("state", "./state", "persistent state directory")
+	config := flag.String("config", "./config.json", "lock and reader configuration JSON")
+	webAddr := flag.String("web-addr", "127.0.0.1:8099", "configuration/pairing web address")
+	ingress := flag.Bool("ingress", false, "restrict Web UI to the HA ingress gateway")
+	status := flag.Bool("status", false, "print credential counts per lock and exit")
 	flag.Parse()
-	if *bleReader != "" {
-		if err := blegateway.ValidateAddress(*bleReader); err != nil {
-			return err
-		}
-		if !strings.HasPrefix(*bleAdapter, "hci") || strings.ContainsAny(*bleAdapter, "/. ") {
-			return fmt.Errorf("invalid BLE adapter")
-		}
-		if *bleInterval < time.Second {
-			return fmt.Errorf("BLE PING interval must be at least 1s")
-		}
-	}
 	if flag.NArg() != 0 {
 		return fmt.Errorf("unexpected positional argument")
 	}
-	if err := os.MkdirAll(*state, 0700); err != nil {
-		return err
-	}
-	if err := os.Chmod(*state, 0700); err != nil {
+	c, err := app.Load(*config)
+	if err != nil {
 		return err
 	}
-	// Atomic state replacement permits a read-only summary while the server runs.
-	if *status {
-		store, err := provision.Open(*state)
-		if err != nil {
+	var ifaces []string
+	if c.Interface != "" {
+		if _, err := net.InterfaceByName(c.Interface); err != nil {
 			return err
 		}
-		return json.NewEncoder(os.Stdout).Encode(store.Summary())
+		ifaces = []string{c.Interface}
 	}
-	unlock, err := lockState(filepath.Join(*state, ".lock"))
-	if err != nil {
+	if err = os.MkdirAll(*state, 0700); err != nil {
 		return err
 	}
-	defer unlock()
-	store, err := provision.Open(*state)
-	if err != nil {
+	if err = os.Chmod(*state, 0700); err != nil {
 		return err
 	}
-	if _, _, err := net.SplitHostPort(*addr); err != nil {
-		return fmt.Errorf("invalid -addr: %w", err)
-	}
-	var ifaces []string
-	if *iface != "" {
-		if _, err := net.InterfaceByName(*iface); err != nil {
-			return err
+	if !*status {
+		unlock, e := lockState(filepath.Join(*state, ".lock"))
+		if e != nil {
+			return e
 		}
-		ifaces = []string{*iface}
-	}
-	p := strings.ReplaceAll(*pin, "-", "")
-	if p == "" {
-		v, e := store.Get("provisioning-pin")
-		if e == nil {
-			p = string(v)
-		} else if !errors.Is(e, os.ErrNotExist) {
+		defer unlock()
+	}
+	var locks []*app.Lock
+	summaries := map[string]provision.Summary{}
+	for _, cfg := range c.Locks {
+		dir := filepath.Join(*state, "locks", cfg.ID)
+		store, e := provision.Open(dir)
+		if e != nil {
 			return e
 		}
-	}
-	if p == "" {
-		for {
-			n, e := rand.Int(rand.Reader, big.NewInt(100000000))
-			if e != nil {
-				return e
-			}
-			p = fmt.Sprintf("%08d", n)
-			if !hap.InvalidPins[p] {
-				break
-			}
+		if *status {
+			summaries[cfg.ID] = store.Summary()
+			continue
 		}
-	}
-	if len(p) != 8 || hap.InvalidPins[p] {
-		return fmt.Errorf("PIN must be 8 digits and not a prohibited pattern")
-	}
-	for _, r := range p {
-		if r < '0' || r > '9' {
-			return fmt.Errorf("PIN must contain only digits")
+		// Retire the old persistent setup code; HAP identities and pairings stay.
+		if e = store.Delete("provisioning-pin"); e != nil && !errors.Is(e, os.ErrNotExist) {
+			return e
 		}
+		l, e := app.NewLock(cfg, store, ifaces)
+		if e != nil {
+			return e
+		}
+		locks = append(locks, l)
 	}
-	if err := store.Set("provisioning-pin", []byte(p)); err != nil {
-		return err
-	}
-	dev, err := hkserver.New(store, hkserver.Config{Name: *name, Serial: *serial, Finish: *finish, PIN: p, Address: *addr, Interfaces: ifaces})
-	if err != nil {
-		return err
+	if *status {
+		return json.NewEncoder(os.Stdout).Encode(summaries)
 	}
-	fmt.Printf("%s — virtual HomeKit lock\n", *name)
-	fmt.Printf("\"Home\" app from Apple > Add Accessory > More Options > %s\n", *name)
-	fmt.Printf("Pairing code: %s-%s\n", p[:4], p[4:])
-	fmt.Println("NFC authentication enabled when a BLE reader is configured; virtual lock only.")
-	log.Printf("HAP address %s; persistent state %s", *addr, *state)
 	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
 	defer cancel()
-	var bleDone chan struct{}
-	if *bleReader != "" {
-		bleDone = make(chan struct{})
-		go func() {
-			defer close(bleDone)
-			blegateway.Run(ctx, *bleReader, *bleAdapter, *bleInterval, func(authCtx context.Context, card blegateway.Card, relay *blegateway.Relay) (err error) {
-				log.Printf("NFC card reader=%s session=%d uid_len=%d sak=0x%02x", *bleReader, card.Session, len(card.UID), card.SAK)
-				if card.SAK&0x20 == 0 {
-					return fmt.Errorf("card does not support ISO-DEP")
-				}
-				success := false
-				defer func() {
-					// Best-effort failure UX even if the authentication deadline expired.
-					finishCtx, done := context.WithTimeout(ctx, 2*time.Second)
-					defer done()
-					if e := nfcauth.Finish(finishCtx, relay, success); e != nil {
-						log.Printf("NFC control flow: %v", e)
-					}
-				}()
-				credentials := store.AuthenticationCredentials()
-				result, err := nfcauth.Authenticate(authCtx, relay, credentials)
+	var wg sync.WaitGroup
+	failures := make(chan error, len(locks)+1)
+	for _, l := range locks {
+		wg.Add(1)
+		go func(l *app.Lock) {
+			defer wg.Done()
+			if err := l.Device.Server.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
+				failures <- fmt.Errorf("lock %s: %w", l.Config.ID, err)
+				cancel()
+			}
+		}(l)
+	}
+	for _, r := range c.Readers {
+		assigned := app.Assigned(locks, r.ID)
+		if len(assigned) == 0 {
+			continue
+		}
+		wg.Add(1)
+		go func(r app.ReaderConfig, assigned []*app.Lock) {
+			defer wg.Done()
+			lastGroupError := ""
+			blegateway.Run(ctx, r.Address, r.Adapter, 10*time.Second, func(authCtx context.Context, card blegateway.Card, relay *blegateway.Relay) error {
+				return app.Authenticate(authCtx, ctx, assigned, r.ID, card, relay)
+			}, func() []byte {
+				group, err := app.Group(assigned)
 				if err != nil {
-					return err
-				}
-				if err = store.SaveAuthenticatedKey(credentials, result.IssuerID, result.EndpointID, result.PublicKey, result.PersistentKey); err != nil {
-					return err
-				}
-				if authCtx.Err() != nil {
-					return authCtx.Err()
-				}
-				err = store.WithAuthorization(credentials, result.IssuerID, result.EndpointID, result.PublicKey, func() error {
-					if authCtx.Err() != nil {
-						return authCtx.Err()
-					}
-					if e := dev.Lock.LockTargetState.SetValue(0); e != nil {
-						return e
+					if lastGroupError != err.Error() {
+						log.Print(err)
+						lastGroupError = err.Error()
 					}
-					return dev.Lock.LockCurrentState.SetValue(0)
-				})
-				if err != nil {
-					return err
+					return nil
 				}
-				success = true
-				log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s; virtual lock unlocked", *bleReader, card.Session, result.EndpointID)
-				return nil
-			}, store.ReaderGroupIdentifier)
-		}()
-	}
-	err = dev.Server.ListenAndServe(ctx)
-	stopped := ctx.Err() != nil
-	cancel()
-	if bleDone != nil {
-		<-bleDone
-	}
-	if stopped || errors.Is(err, context.Canceled) || errors.Is(err, http.ErrServerClosed) || err == nil {
+				lastGroupError = ""
+				return group
+			})
+		}(r, assigned)
+	}
+	web := &http.Server{Addr: *webAddr, Handler: app.Handler(locks, c.Readers, *ingress), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		if err := web.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+			failures <- err
+			cancel()
+		}
+	}()
+	log.Printf("Configured %d locks and %d readers; pairing UI at %s", len(locks), len(c.Readers), *webAddr)
+	<-ctx.Done()
+	shutdown, done := context.WithTimeout(context.Background(), 5*time.Second)
+	defer done()
+	_ = web.Shutdown(shutdown)
+	wg.Wait()
+	select {
+	case err := <-failures:
+		return err
+	default:
 		return nil
 	}
-	return err
 }
diff --git a/config.example.json b/config.example.json
new file mode 100644
index 0000000..6276fa0
--- /dev/null
+++ b/config.example.json
@@ -0,0 +1,19 @@
+{
+  "interface": "",
+  "locks": [
+    {
+      "id": "GO-HOMEKEY-001",
+      "name": "Go Home Key",
+      "port": 51826,
+      "finish": "silver",
+      "readers": ["porch"]
+    }
+  ],
+  "readers": [
+    {
+      "id": "porch",
+      "address": "70:AF:09:16:42:5A",
+      "adapter": "hci0"
+    }
+  ]
+}
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index a2a1859..8dedcbc 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,12 @@
+## 0.0.6
+
+- Configure lock and reader lists; remove single-lock startup options.
+- Preserve independent HomeKit identities, pairings and credentials per lock.
+- Share one BLE connection across assigned locks with per-lock authorization.
+- Add HA ingress Web UI with a five-minute HomeKit pairing window.
+- Keep setup codes in memory; hide after pairing and block setup outside a window.
+- Preserve existing single-lock state through a one-time ID binding.
+
 ## 0.0.5
 - Acknowledge removal of the placeholder returned by GET
 - Supply the provisioned Home group identifier to matching ESP32 firmware over BLE.
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index 1a52f05..fff56e6 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -1,153 +1,85 @@
 # Home Key Go on Home Assistant OS
 
-## Local installation (no published image required)
-
-After adding these files to the root of the homekey-go source repository:
-
-```sh
-sh scripts/stage-ha-app.sh
-```
-
-Copy the resulting `dist/homekey_go` directory into HA OS's `/addons` directory,
-using the Samba `addons` share or an SSH app. The result must be
-`/addons/homekey_go/config.yaml`, alongside its Dockerfile and source directories.
-In HA, open Settings > Apps > App store, select Check for updates from the
-three-dot menu, then install Home Key Go under Local apps.
-On older HA versions the UI calls these Add-ons.
-
-Enable Start on boot, start the app, and open Logs. Use the pairing code printed
-by the Go service in Apple Home > Add Accessory > More Options > Go Home Key.
-The container image build downloads Go dependencies, including the patched HAP
-fork specified by go.mod. Internet access is needed during the initial build.
-
 ## Configuration
 
-```yaml
-name: Go Home Key
-serial: GO-HOMEKEY-001
-port: 51826
-interface: ""
-finish: silver
-pin: ""
-```
-
-- `name`: accessory name shown in Apple Home.
-- `serial`: stable accessory serial; keep it unchanged after pairing.
-- `port`: HAP TCP port on the HA OS host. Change it if 51826 is occupied.
-- `interface`: HA OS LAN interface, e.g. `end0` or `enp3s0`; empty selects
-  eligible interfaces. Use the actual HA host interface name.
-- `finish`: `silver`, `black`, `gold`, or `tan` Wallet artwork.
-- `pin`: empty generates and saves a random PIN, then reuses it on later starts.
-  An explicit PIN must have eight digits (hyphens are accepted by the CLI);
-  prohibited HomeKit PIN patterns are rejected by the service.
-
-Save configuration changes and restart the app.
-The app uses host networking for HomeKit mDNS and binds HAP to `:<port>`.
-Your phone must be able to reach the HA host and receive its mDNS advertisements.
-
-## Persistent state and moving an existing pairing
-
-The service is started with `-state /data/state`. HomeKit identity, paired
-controllers, Home Key credentials, and the generated PIN are stored together
-in `/data/state/state.json`. This is the app's persistent data volume, not the
-HA configuration directory. App backups use cold mode so the service is stopped
-while its state is backed up.
-
-For a first installation you can pair a fresh accessory. To preserve an existing
-pairing from your Mac, stop the Mac service and the HA app, then copy the entire
-existing state directory into the app's `/data/state` through a method with
-access to that app's data volume. The ordinary `/addons` source folder and HA
-`/config` directory are NOT that volume. Keep the same name, serial, and explicit
-PIN, if one was configured. Run only one instance with that identity.
-
-Do not delete state or uninstall the app as an update procedure: losing the
-state means losing its pairing identity and credentials. Keep an app backup.
-
-## Building a container outside HA
-
-From the source repository root:
-
-```sh
-docker build -t homekey-go:0.0.1 .
-```
-
-On a Linux Docker host, run it with a persistent volume and host networking:
-
-```sh
-docker run --rm --network host -v homekey-state:/data homekey-go:0.0.1
-```
-
-Without `/data/options.json`, the CLI uses its default accessory settings with
-state fixed to `/data/state`. Explicit container arguments are passed directly
-to the CLI, so include `-state /data/state` when supplying custom arguments:
-
-```sh
-docker run --rm --network host -v homekey-state:/data homekey-go:0.0.1 \
-  -state /data/state -name 'Go Home Key' -addr :51827
-```
-
-Docker Desktop on macOS is not equivalent to a Linux HA OS host for LAN mDNS.
-Use HA OS for the actual discovery and pairing test.
-
-## Publishing and repository installation
-
-`ha-app/config.yaml` is set up to pull `ghcr.io/tgckpg/homekey-go:0.0.1`.
-That image has NOT been published by this packaging work. If you use another
-registry/namespace, edit its `image` field. Build and push both supported
-architectures before attempting repository installation:
-
-```sh
-docker login ghcr.io
-docker buildx build --platform linux/amd64,linux/arm64 \
-  --build-arg BUILD_ARCH='aarch64|amd64' \
-  --build-arg BUILD_VERSION=0.0.1 \
-  -t ghcr.io/tgckpg/homekey-go:0.0.1 --push .
-```
-
-Make the package public if HA will pull it without registry credentials.
-Commit the packaging files to a cloneable Git repository, then add its Git URL
-under App store > Repositories. Use the real Git repository endpoint, not the
-static `sgit` HTML tree URL. Supervisor pulls the image using `config.yaml`.
-The root `repository.yaml` declares this repository; `ha-app` contains the app.
-Once the image is published, those metadata files can instead live in your
-separate HA app repository; the root Dockerfile and Go source stay here.
-
-For releases, update the app version in config.yaml and CHANGELOG.md, build and
-publish the matching image tag, then commit the metadata. Preserve the app slug
-and repository identity to keep existing installs on their update path.
-
-## Current functionality
-
-See [NFC.md](NFC.md) for matching firmware, Wallet test steps, expected logs
-and current limitations. Keep the existing HomeKit state and app slug when
-updating.
-
-## BLE gateway configuration
+Edit the app Configuration tab (YAML mode supports nested lists), save and
+restart. There are no accessory-specific startup flags or persistent PIN option.
 
 ```yaml
-ble_reader: "70:AF:09:16:42:5A"
-ble_adapter: hci0
-```
-
-An empty `ble_reader` disables the gateway. The app uses host D-Bus; the BlueZ
-adapter must be powered and recognized by HA OS. Failures reconnect after
-5 seconds without stopping HAP. Only one gateway should connect to the reader.
-This backend connects directly and does not use ESPHome proxies or HACS.
-
-Standalone Linux:
-
-```sh
-go run ./cmd/homekey -ble-reader 70:AF:09:16:42:5A -ble-adapter hci0
+interface: ""
+locks:
+  - id: GO-HOMEKEY-001
+    name: Go Home Key
+    port: 51826
+    finish: silver
+    readers: [porch]
+  - id: back-door
+    name: Back Door
+    port: 51827
+    finish: black
+    readers: [porch]
+readers:
+  - id: porch
+    address: "70:AF:09:16:42:5A"
+    adapter: hci0
 ```
 
-PING/PONG remains on the original read/write characteristic. The optional
-`-ble-ping-interval 10s` flag has a minimum of 1 second. Ping pauses while a
-card's authentication transaction is running. Two additional characteristics
-provide NFC session status and APDU exchange.
-
-## Validation
-
-Go tests with the race detector, vet and a Linux build pass. Firmware UART and
-mailbox host tests pass with AddressSanitizer/UndefinedBehaviorSanitizer.
-ESP-IDF compilation and end-to-end radio testing still require your toolchain
-and hardware; they have not been performed here.
+Each lock ID is immutable and supplies its serial. Name, finish, port and
+reader assignments can be edited. Ports must be unique; 8099 is reserved for
+the Web UI. A reader address can appear only once. IDs use letters, digits,
+underscores and hyphens (1–64 characters). Use `readers: []` if no reader is
+available yet. Use `locks: []` for an installation without locks.
+
+Shared readers connect once and serve all assigned locks. Only locks that
+actively enroll the authenticated Home Key endpoint unlock. The current ESP32
+firmware supports one Home Key ECP group; assigned locks must share that group,
+normally by pairing into the same Apple Home. Different groups reject taps.
+Only one running service should connect to a reader. BlueZ adapter failures
+reconnect independently of HomeKit.
+
+## Pairing
+
+Click **Open Web UI**, then **Pair** beside a lock. Add that named accessory in
+Apple Home → Add Accessory → More Options, using the displayed code.
+A window lasts five minutes. The code disappears after pairing or cancellation;
+expiry and restarting also close setup. Pairing is rejected outside the window.
+An already paired lock cannot open another setup window. To pair afresh, remove
+it in Apple Home first. Existing connections use stored pairing keys.
+
+The PIN is random for each service run and held only in memory. A repeated
+window during the same run uses the same code. Codes are never printed to logs
+or persisted. The Web UI is available through authenticated HA ingress only;
+direct LAN access to port 8099 is rejected.
+
+## Upgrading existing single-lock installations
+
+Back up the app. Replace the old `name`, `serial`, `port`, `finish`, `pin`,
+`ble_reader`, and `ble_adapter` options with the new structure. For the first
+upgraded start, configure **exactly one lock**, using the old serial as its
+`id`, old name, old finish, and old port. Define the reader separately and
+assign its ID to this lock. Keep the complete `/data/state` directory.
+
+The old root state is bound to that ID once, preserving HAP identity, pairing
+and Home Key credentials. The obsolete saved PIN is deleted. Add further
+locks after this first upgraded start. New lock state lives under
+`/data/state/locks/<id>`. Keep IDs unchanged and preserve all state in backups.
+
+Removing a lock from configuration stops serving it but preserves its state.
+Adding its ID back restores its identity and credentials. Configuration edits
+require restart. No MQTT, HA integration, or physical actuator is included yet.
+
+## Local install and release
+
+Run `sh scripts/stage-ha-app.sh`, copy `dist/homekey_go` into `/addons`, refresh
+the HA app store, install and start it. Local staging removes the image field
+so HA builds the included Dockerfile. Repository installations pull the image
+matching the version in `config.yaml`; publish the 0.0.6 image before updating
+that metadata in your app repository. Keep the app slug unchanged.
+
+Standalone builds use `make`, copy `config.example.json` to `config.json`, then
+run `./bin/homekey -config config.json`. The Web UI defaults to loopback port
+8099. Linux host networking and host D-Bus are needed for container deployment
+with BLE; supply a mounted JSON config and persistent state. With explicit
+container arguments, include `-state /data/state`.
+
+See [NFC.md](NFC.md) for firmware and phone testing.
diff --git a/ha-app/NFC.md b/ha-app/NFC.md
index 785d432..d9fa2d5 100644
--- a/ha-app/NFC.md
+++ b/ha-app/NFC.md
@@ -24,15 +24,9 @@ physical actuator integration in this release.
    using the existing repository workflow. Update the installed app and restart
    it. Keep its existing persistent `/data/state`.
 
-3. Keep the current app configuration:
-
-   ```yaml
-   ble_reader: "70:AF:09:16:42:5A"
-   ble_adapter: hci0
-   ```
-
-   The HA app still needs host D-Bus and host networking. No new port is added.
-   The HACS integration is not involved in this direct Go-to-ESP32 connection.
+3. Configure lock and reader lists as described in [DOCS.md](DOCS.md), then
+   restart. Assign a reader's ID to the desired locks. The app needs host D-Bus,
+   host networking, and one TCP HomeKit port per lock. Open Web UI for pairing.
 
 4. Hold the phone at the reader with its existing Home Key installed. ECP
    should select the key; Express Mode can authenticate without opening Wallet.
@@ -45,10 +39,9 @@ Expected Go milestones:
 ```text
 BLE gateway: connected to ...; APDU write_size=...
 BLE gateway: Home Key ECP configured reader=... enabled=true
-NFC card reader=... session=... uid_len=... sak=0x20
 Home Key SELECT accepted; protocol=2.0
 Home Key AUTH0 key exchange complete
-Home Key authenticated reader=... session=... endpoint=...; virtual lock unlocked
+Home Key authenticated reader=... session=... endpoint=... lock=...; virtual lock unlocked
 ```
 
 An ordinary card can be detected without having the Home Key applet. SELECT
diff --git a/ha-app/README.md b/ha-app/README.md
index 508f4d7..051d9a7 100644
--- a/ha-app/README.md
+++ b/ha-app/README.md
@@ -1,7 +1,8 @@
 # Home Key Go
 
-Virtual HomeKit lock with Apple Home Key provisioning, packaged for Home Assistant OS.
-See [DOCS.md](DOCS.md) for installation and configuration.
+HomeKit virtual locks with Apple Home Key NFC authentication, packaged for HA OS.
+Configure multiple locks and readers in Configuration; use Open Web UI to pair.
+One reader can serve several locks in the same Home Key group.
 
-This version provisions Home Key credentials and simulates lock state changes.
-It does not yet connect an ESP32/PN532 reader or operate a physical lock.
+See [DOCS.md](DOCS.md) for setup and migration. Physical locks and HA entity
+integration are separate work.
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index 6092ba0..4f0ecfd 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.5"
+version: "0.0.6"
 slug: homekey_go
 description: HomeKit virtual lock with Apple Home Key NFC authentication
 url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/
@@ -14,21 +14,28 @@ host_dbus: true
 backup: cold
 stage: experimental
 image: ghcr.io/tgckpg/homekey-go
+ingress: true
+ingress_port: 8099
+panel_title: Home Key locks
 options:
-  name: Go Home Key
-  serial: GO-HOMEKEY-001
-  port: 51826
   interface: ""
-  finish: silver
-  pin: ""
-  ble_reader: ""
-  ble_adapter: hci0
+  locks:
+    - id: GO-HOMEKEY-001
+      name: Go Home Key
+      port: 51826
+      finish: silver
+      readers: []
+  readers: []
 schema:
-  name: str(1,)
-  serial: str(1,)
-  port: port
   interface: str
-  finish: list(silver|black|gold|tan)
-  pin: password
-  ble_reader: str?
-  ble_adapter: str?
+  locks:
+    - id: str(1,64)
+      name: str(1,)
+      port: port
+      finish: list(silver|black|gold|tan)
+      readers:
+        - str
+  readers:
+    - id: str(1,64)
+      address: str
+      adapter: str
diff --git a/ha-app/run.sh b/ha-app/run.sh
index f0ee495..a7b41dc 100755
--- a/ha-app/run.sh
+++ b/ha-app/run.sh
@@ -1,45 +1,7 @@
 #!/bin/sh
 set -eu
 umask 077
-
-# Explicit arguments are passed directly to the CLI (e.g. -status -state ...).
 if [ "$#" -gt 0 ]; then
     exec /usr/local/bin/homekey "$@"
 fi
-
-set -- -state /data/state
-if [ -f /data/options.json ]; then
-    jq -e '
-        type == "object" and
-        (.name | type == "string" and length > 0) and
-        (.serial | type == "string" and length > 0) and
-        (.port | type == "number" and . == floor and . >= 1 and . <= 65535) and
-        (.interface | type == "string") and
-        (.finish | . == "silver" or . == "black" or . == "gold" or . == "tan") and
-        (.pin | type == "string")
-    ' /data/options.json >/dev/null || {
-        echo 'Invalid Home Key Go options; check the app configuration.' >&2
-        exit 1
-    }
-    name=$(jq -r '.name' /data/options.json)
-    serial=$(jq -r '.serial' /data/options.json)
-    port=$(jq -r '.port' /data/options.json)
-    iface=$(jq -r '.interface' /data/options.json)
-    finish=$(jq -r '.finish' /data/options.json)
-    ble_reader=$(jq -r '.ble_reader // ""' /data/options.json)
-    ble_adapter=$(jq -r '.ble_adapter // "hci0"' /data/options.json)
-    if [ -n "$ble_reader" ]; then
-        set -- "$@" -ble-reader "$ble_reader" -ble-adapter "$ble_adapter"
-    fi
-    pin=$(jq -r '.pin' /data/options.json)
-    set -- "$@" -name "$name" -serial "$serial" -addr ":$port" -finish "$finish"
-    if [ -n "$iface" ]; then
-        set -- "$@" -interface "$iface"
-    fi
-    if [ -n "$pin" ]; then
-        set -- "$@" -pin "$pin"
-    fi
-fi
-
-# exec lets the Go service receive SIGTERM and release its state lock.
-exec /usr/local/bin/homekey "$@"
+exec /usr/local/bin/homekey -state /data/state -config /data/options.json -web-addr 0.0.0.0:8099 -ingress
diff --git a/ha-app/translations/en.yaml b/ha-app/translations/en.yaml
index 1cbd0e7..09e3dff 100644
--- a/ha-app/translations/en.yaml
+++ b/ha-app/translations/en.yaml
@@ -1,25 +1,10 @@
 configuration:
-  name:
-    name: Accessory name
-    description: Name shown in Apple Home.
-  serial:
-    name: Serial number
-    description: Stable accessory serial. Keep unchanged once paired.
-  port:
-    name: HomeKit port
-    description: TCP listen port on the HA OS host. Must be unused by other services.
   interface:
     name: LAN interface
-    description: Host network interface for mDNS. Leave empty for automatic selection.
-  finish:
-    name: Wallet artwork
-    description: Home Key appearance in Apple Wallet.
-  pin:
-    name: Pairing PIN
-    description: Leave empty to generate and save a PIN. Otherwise use eight digits.
-  ble_reader:
-    name: BLE reader address
-    description: ESP32 MAC from its serial log. Leave empty to disable the PING probe.
-  ble_adapter:
-    name: Bluetooth adapter
-    description: Local BlueZ adapter, usually hci0.
+    description: Interface used by every lock for mDNS; leave empty for automatic selection.
+  locks:
+    name: Locks
+    description: Each lock needs an immutable ID, name, unique HomeKit port, artwork finish, and a list of reader IDs. Save and restart after editing.
+  readers:
+    name: NFC readers
+    description: Each reader needs an ID, ESP32 BLE address, and BlueZ adapter (usually hci0). Assign its ID to one or more locks.
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
new file mode 100644
index 0000000..cf28670
--- /dev/null
+++ b/internal/app/app_test.go
@@ -0,0 +1,229 @@
+package app
+
+import (
+	"bytes"
+	"context"
+	"crypto/elliptic"
+	"encoding/hex"
+	"encoding/json"
+	"homekey.local/provisioner/internal/nfcauth"
+	"homekey.local/provisioner/internal/provision"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+)
+
+func testLock(t *testing.T) *Lock {
+	t.Helper()
+	s, err := provision.Open(t.TempDir())
+	if err != nil {
+		t.Fatal(err)
+	}
+	l, err := NewLock(LockConfig{ID: "front-door", Name: "Front Door", Port: 51826, Finish: "silver", Readers: []string{"porch"}}, s, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return l
+}
+func setupRequest(l *Lock) *httptest.ResponseRecorder {
+	w := httptest.NewRecorder()
+	l.Device.Server.ServeMux().(http.Handler).ServeHTTP(w, httptest.NewRequest("POST", "/pair-setup", bytes.NewReader([]byte{6, 1, 1, 0, 1, 0})))
+	return w
+}
+func TestPairingWindow(t *testing.T) {
+	l := testLock(t)
+	if s := l.Status(); s.PIN != "" {
+		t.Fatal("PIN visible before Pair")
+	}
+	if r := setupRequest(l); !bytes.Equal(r.Body.Bytes(), []byte{6, 1, 2, 7, 1, 6}) {
+		t.Fatalf("closed window allowed setup: %x", r.Body.Bytes())
+	}
+	if err := l.Pair(); err != nil {
+		t.Fatal(err)
+	}
+	if l.Status().PIN == "" {
+		t.Fatal("Pair did not show code")
+	}
+	if r := setupRequest(l); bytes.Equal(r.Body.Bytes(), []byte{6, 1, 2, 7, 1, 6}) || r.Code != 200 {
+		t.Fatalf("open setup failed: %d %x", r.Code, r.Body.Bytes())
+	}
+	l.CancelPair()
+	if l.Status().PIN != "" {
+		t.Fatal("cancel retained code")
+	}
+	l.Pair()
+	l.until = time.Now().Add(-time.Second)
+	if l.Status().PIN != "" {
+		t.Fatal("expired code visible")
+	}
+	if r := setupRequest(l); !bytes.Equal(r.Body.Bytes(), []byte{6, 1, 2, 7, 1, 6}) {
+		t.Fatal("expired pairing allowed")
+	}
+	l.Pair()
+	raw, _ := json.Marshal(struct {
+		Name       string
+		PublicKey  []byte
+		Permission byte
+	}{"phone", bytes.Repeat([]byte{42}, 32), 1})
+	if err := l.Store.Set("phone.pairing", raw); err != nil {
+		t.Fatal(err)
+	}
+	if l.Status().PIN != "" {
+		t.Fatal("paired lock leaked setup code")
+	}
+	if err := l.Pair(); err == nil {
+		t.Fatal("already paired lock reopened")
+	}
+	if r := setupRequest(l); !bytes.Equal(r.Body.Bytes(), []byte{6, 1, 2, 7, 1, 6}) {
+		t.Fatal("paired lock allowed setup")
+	}
+}
+func TestConfigurationValidation(t *testing.T) {
+	c := Config{Locks: []LockConfig{{ID: "front", Name: "Front", Port: 51826, Finish: "silver", Readers: []string{"porch"}}, {ID: "back", Name: "Back", Port: 51827, Finish: "black", Readers: []string{"porch"}}}, Readers: []ReaderConfig{{ID: "porch", Address: "70:AF:09:16:42:5A", Adapter: "hci0"}}}
+	if err := c.Validate(); err != nil {
+		t.Fatal(err)
+	}
+	for _, change := range []func(*Config){func(c *Config) { c.Locks[0].ID = "../escape" }, func(c *Config) { c.Locks[1].ID = "front" }, func(c *Config) { c.Locks[1].Port = 51826 }, func(c *Config) { c.Locks[0].Readers = []string{"missing"} }, func(c *Config) { c.Readers[0].Adapter = "hci/0" }, func(c *Config) {
+		c.Readers = append(c.Readers, ReaderConfig{ID: "other", Address: "70:af:09:16:42:5a", Adapter: "hci1"})
+	}} {
+		b, _ := json.Marshal(c)
+		var next Config
+		json.Unmarshal(b, &next)
+		change(&next)
+		if next.Validate() == nil {
+			t.Fatal("invalid configuration accepted")
+		}
+	}
+}
+func TestIngressAndActions(t *testing.T) {
+	l := testLock(t)
+	handler := Handler([]*Lock{l}, nil, true)
+	request := func(remote, path, header string) *httptest.ResponseRecorder {
+		r := httptest.NewRequest("POST", path, nil)
+		r.RemoteAddr = remote
+		r.Header.Set("X-Homekey-Action", header)
+		r.Header.Set("Origin", "https://ha.example.com")
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+		return w
+	}
+	if w := request("10.0.0.1:1000", "/api/locks/front-door/pair", "pairing"); w.Code != 403 {
+		t.Fatal("LAN bypassed ingress")
+	}
+	if w := request("172.30.32.2:1000", "/api/locks/front-door/pair", ""); w.Code != 403 {
+		t.Fatal("missing action header accepted")
+	}
+	if w := request("172.30.32.2:1000", "/api/locks/front-door/pair", "pairing"); w.Code != 204 {
+		t.Fatalf("HA origin rejected: %d %s", w.Code, w.Body.String())
+	}
+	if w := request("172.30.32.2:1000", "/api/locks/front-door/cancel", "pairing"); w.Code != 204 || l.Status().PIN != "" {
+		t.Fatal("cancel failed")
+	}
+}
+func TestReaderAssignmentsAndGroups(t *testing.T) {
+	a, b := testLock(t), testLock(t)
+	b.Config.ID = "back"
+	b.Config.Readers = []string{"other"}
+	if assigned := Assigned([]*Lock{a, b}, "porch"); len(assigned) != 1 || assigned[0] != a {
+		t.Fatal("incorrect assignment")
+	}
+	b.Config.Readers = []string{"porch"}
+	if len(Assigned([]*Lock{a, b}, "porch")) != 2 {
+		t.Fatal("shared reader not supported")
+	}
+	// Use HomeKit pairing + normal provisioning control to install group keys.
+	for i, l := range []*Lock{a, b} {
+		raw, _ := json.Marshal(struct {
+			Name       string
+			PublicKey  []byte
+			Permission byte
+		}{"phone", bytes.Repeat([]byte{42}, 32), 1})
+		if err := l.Store.Set("phone.pairing", raw); err != nil {
+			t.Fatal(err)
+		}
+		key := make([]byte, 32)
+		key[31] = byte(i + 1)
+		body := append(provision.TLV(1, []byte{2}), provision.TLV(2, key)...)
+		body = append(body, provision.TLV(3, []byte("reader01"))...)
+		request := append(provision.TLV(1, []byte{2}), provision.TLV(6, body)...)
+		if _, err := l.Store.Handle(request); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if _, err := Group([]*Lock{a, b}); err == nil {
+		t.Fatal("different Home groups accepted")
+	}
+	if group, err := Group([]*Lock{a}); err != nil || len(group) != 8 {
+		t.Fatal("single group rejected")
+	}
+}
+
+func TestSharedReaderPerLockAuthorization(t *testing.T) {
+	a, b, c := testLock(t), testLock(t), testLock(t)
+	b.Config.ID = "back"
+	c.Config.ID = "unenrolled"
+	snapshots := map[*Lock]provision.Credentials{}
+	result := &nfcauth.Result{}
+	for _, l := range []*Lock{a, b, c} {
+		raw, _ := json.Marshal(struct {
+			Name       string
+			PublicKey  []byte
+			Permission byte
+		}{"phone", bytes.Repeat([]byte{42}, 32), 1})
+		if err := l.Store.Set("phone.pairing", raw); err != nil {
+			t.Fatal(err)
+		}
+		key := make([]byte, 32)
+		key[31] = 1
+		body := append(provision.TLV(1, []byte{2}), provision.TLV(2, key)...)
+		body = append(body, provision.TLV(3, []byte("reader01"))...)
+		if _, err := l.Store.Handle(append(provision.TLV(1, []byte{2}), provision.TLV(6, body)...)); err != nil {
+			t.Fatal(err)
+		}
+		if l != c {
+			var iid string
+			for id := range l.Store.AuthenticationCredentials().Issuers {
+				iid = id
+			}
+			issuerID, _ := hex.DecodeString(iid)
+			x, y := elliptic.P256().ScalarBaseMult([]byte{2})
+			pub := elliptic.Marshal(elliptic.P256(), x, y)
+			body := append(provision.TLV(1, []byte{2}), provision.TLV(2, pub[1:])...)
+			body = append(body, provision.TLV(3, issuerID)...)
+			body = append(body, provision.TLV(4, []byte{1})...)
+			if _, err := l.Store.Handle(append(provision.TLV(1, []byte{2}), provision.TLV(4, body)...)); err != nil {
+				t.Fatal(err)
+			}
+			for eid, ep := range l.Store.AuthenticationCredentials().Issuers[iid].Endpoints {
+				result.IssuerID = iid
+				result.EndpointID = eid
+				result.PublicKey = ep.PublicKey
+			}
+		}
+		snapshots[l] = l.Store.AuthenticationCredentials()
+	}
+	groupKey := snapshots[a].ReaderPrivateKey
+	if n := unlockAuthorized(context.Background(), []*Lock{a, b, c}, snapshots, groupKey, result, "porch", 1); n != 2 {
+		t.Fatalf("unlocked %d locks, want 2", n)
+	}
+	if c.Device.Lock.LockCurrentState.Value() != 1 {
+		t.Fatal("unenrolled lock unlocked")
+	}
+	a.Device.Lock.LockCurrentState.SetValue(1)
+	b.Device.Lock.LockCurrentState.SetValue(1)
+	if err := b.Store.Delete("phone.pairing"); err != nil {
+		t.Fatal(err)
+	}
+	if n := unlockAuthorized(context.Background(), []*Lock{a, b, c}, snapshots, groupKey, result, "porch", 2); n != 1 {
+		t.Fatalf("revoked lock authorized: %d", n)
+	}
+	if b.Device.Lock.LockCurrentState.Value() != 1 {
+		t.Fatal("revoked lock unlocked")
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+	if n := unlockAuthorized(ctx, []*Lock{a}, snapshots, groupKey, result, "porch", 3); n != 0 {
+		t.Fatal("expired authentication unlocked")
+	}
+}
diff --git a/internal/app/config.go b/internal/app/config.go
new file mode 100644
index 0000000..7ef491b
--- /dev/null
+++ b/internal/app/config.go
@@ -0,0 +1,92 @@
+package app
+
+import (
+	"encoding/json"
+	"fmt"
+	"homekey.local/provisioner/internal/blegateway"
+	"os"
+	"regexp"
+	"strings"
+)
+
+type LockConfig struct {
+	ID      string   `json:"id"`
+	Name    string   `json:"name"`
+	Port    int      `json:"port"`
+	Finish  string   `json:"finish"`
+	Readers []string `json:"readers"`
+}
+type ReaderConfig struct {
+	ID      string `json:"id"`
+	Address string `json:"address"`
+	Adapter string `json:"adapter"`
+}
+type Config struct {
+	Interface string         `json:"interface"`
+	Locks     []LockConfig   `json:"locks"`
+	Readers   []ReaderConfig `json:"readers"`
+}
+
+var identifier = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
+
+func Load(path string) (Config, error) {
+	var c Config
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return c, err
+	}
+	// Supervisor may retain removed legacy options. Only the new fields are used.
+	if err = json.Unmarshal(b, &c); err != nil {
+		return c, err
+	}
+	return c, c.Validate()
+}
+func (c Config) Validate() error {
+	readers := map[string]bool{}
+	addresses := map[string]bool{}
+	for _, r := range c.Readers {
+		if !identifier.MatchString(r.ID) || readers[r.ID] {
+			return fmt.Errorf("invalid or duplicate reader ID %q", r.ID)
+		}
+		if err := blegateway.ValidateAddress(r.Address); err != nil {
+			return err
+		}
+		address := strings.ToUpper(r.Address)
+		if addresses[address] {
+			return fmt.Errorf("duplicate reader address %s", address)
+		}
+		if !regexp.MustCompile(`^hci[0-9]+$`).MatchString(r.Adapter) {
+			return fmt.Errorf("invalid reader adapter")
+		}
+		readers[r.ID] = true
+		addresses[address] = true
+	}
+	ids := map[string]bool{}
+	ports := map[int]bool{}
+	for _, l := range c.Locks {
+		if !identifier.MatchString(l.ID) || ids[l.ID] {
+			return fmt.Errorf("invalid or duplicate lock ID %q", l.ID)
+		}
+		if strings.TrimSpace(l.Name) == "" {
+			return fmt.Errorf("lock name is required")
+		}
+		if l.Port < 1 || l.Port > 65535 || ports[l.Port] || l.Port == 8099 {
+			return fmt.Errorf("invalid, reserved or duplicate lock port %d", l.Port)
+		}
+		switch l.Finish {
+		case "silver", "black", "gold", "tan":
+		default:
+			return fmt.Errorf("invalid lock finish")
+		}
+		seen := map[string]bool{}
+		for _, r := range l.Readers {
+			if !readers[r] || seen[r] {
+				return fmt.Errorf("unknown or duplicate reader %q for lock %s", r, l.ID)
+			}
+			seen[r] = true
+		}
+		ids[l.ID] = true
+		ports[l.Port] = true
+	}
+	return nil
+}
diff --git a/internal/app/pairing.go b/internal/app/pairing.go
new file mode 100644
index 0000000..d0a347e
--- /dev/null
+++ b/internal/app/pairing.go
@@ -0,0 +1,104 @@
+package app
+
+import (
+	"crypto/rand"
+	"fmt"
+	"github.com/brutella/hap"
+	"github.com/go-chi/chi"
+	"homekey.local/provisioner/internal/hkserver"
+	"homekey.local/provisioner/internal/provision"
+	"math/big"
+	"net/http"
+	"sync"
+	"time"
+)
+
+type Lock struct {
+	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) {
+	// PIN exists only in process memory. It is never printed or persisted.
+	var pin string
+	for {
+		n, err := rand.Int(rand.Reader, big.NewInt(100000000))
+		if err != nil {
+			return nil, err
+		}
+		pin = fmt.Sprintf("%08d", n)
+		if !hap.InvalidPins[pin] {
+			break
+		}
+	}
+	d, err := hkserver.New(store, hkserver.Config{Name: c.Name, Serial: c.ID, Finish: c.Finish, PIN: pin, Address: fmt.Sprintf(":%d", c.Port), Interfaces: ifaces})
+	if err != nil {
+		return nil, err
+	}
+	l := &Lock{Config: c, Store: store, Device: d, pin: pin}
+	mux := d.Server.ServeMux().(*chi.Mux)
+	var original http.Handler
+	var find func(chi.Routes)
+	find = func(r chi.Routes) {
+		for _, route := range r.Routes() {
+			if route.Pattern == "/pair-setup" {
+				original = route.Handlers["POST"]
+			}
+			if route.SubRoutes != nil {
+				find(route.SubRoutes)
+			}
+		}
+	}
+	find(mux)
+	if original == nil {
+		return nil, fmt.Errorf("HAP pair-setup route missing")
+	}
+	mux.Method("POST", "/pair-setup", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		l.pairing.Lock()
+		defer l.pairing.Unlock()
+		if !time.Now().Before(l.until) || store.Summary().PairedControllers > 0 {
+			w.Header().Set("Content-Type", hap.HTTPContentTypePairingTLV8)
+			w.Write([]byte{6, 1, 2, 7, 1, 6}) // M2 / unavailable
+			return
+		}
+		original.ServeHTTP(w, r)
+		if store.Summary().PairedControllers > 0 {
+			l.until = time.Time{}
+		}
+	}))
+	return l, nil
+}
+func (l *Lock) Pair() error {
+	l.pairing.Lock()
+	defer l.pairing.Unlock()
+	if l.Store.Summary().PairedControllers > 0 {
+		return fmt.Errorf("lock is already paired")
+	}
+	if !time.Now().Before(l.until) {
+		l.until = time.Now().Add(5 * time.Minute)
+	}
+	return nil
+}
+func (l *Lock) CancelPair() { l.pairing.Lock(); defer l.pairing.Unlock(); l.until = time.Time{} }
+
+type LockStatus struct {
+	LockConfig
+	Summary provision.Summary `json:"summary"`
+	PIN     string            `json:"pin,omitempty"`
+	Until   time.Time         `json:"until,omitempty"`
+}
+
+func (l *Lock) Status() LockStatus {
+	l.pairing.Lock()
+	defer l.pairing.Unlock()
+	s := LockStatus{LockConfig: l.Config, Summary: l.Store.Summary()}
+	if s.Summary.PairedControllers == 0 && time.Now().Before(l.until) {
+		s.PIN = l.pin[:3] + "-" + l.pin[3:5] + "-" + l.pin[5:]
+		s.Until = l.until
+	}
+	return s
+}
diff --git a/internal/app/reader.go b/internal/app/reader.go
new file mode 100644
index 0000000..1f14123
--- /dev/null
+++ b/internal/app/reader.go
@@ -0,0 +1,137 @@
+package app
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"homekey.local/provisioner/internal/blegateway"
+	"homekey.local/provisioner/internal/nfcauth"
+	"homekey.local/provisioner/internal/provision"
+	"log"
+	"time"
+)
+
+func Assigned(locks []*Lock, id string) []*Lock {
+	var out []*Lock
+	for _, l := range locks {
+		for _, r := range l.Config.Readers {
+			if r == id {
+				out = append(out, l)
+				break
+			}
+		}
+	}
+	return out
+}
+func Group(locks []*Lock) ([]byte, error) {
+	var group []byte
+	for _, l := range locks {
+		g := l.Store.ReaderGroupIdentifier()
+		if len(g) == 0 {
+			continue
+		}
+		if group != nil && !bytes.Equal(group, g) {
+			return nil, fmt.Errorf("shared reader locks belong to different Home Key groups; use separate readers")
+		}
+		group = g
+	}
+	return group, nil
+}
+func Authenticate(ctx, serviceCtx context.Context, locks []*Lock, reader string, card blegateway.Card, relay *blegateway.Relay) (err error) {
+	if card.SAK&0x20 == 0 {
+		return fmt.Errorf("card does not support ISO-DEP")
+	}
+	success := false
+	defer func() {
+		finishCtx, done := context.WithTimeout(serviceCtx, 2*time.Second)
+		defer done()
+		if e := nfcauth.Finish(finishCtx, relay, success); e != nil {
+			log.Printf("NFC control flow: %v", e)
+		}
+	}()
+	if _, err = Group(locks); err != nil {
+		return err
+	}
+	// All locks in one Apple Home share the reader group key. Use one enrolled
+	// identity for this transaction; recheck each lock's own endpoint enrollment.
+	var source *Lock
+	for _, l := range locks {
+		if s := l.Store.Summary(); s.ReaderProvisioned && s.Endpoints > 0 {
+			source = l
+			break
+		}
+	}
+	if source == nil {
+		return fmt.Errorf("no assigned lock has enrolled Home Key credentials")
+	}
+	snapshots := make(map[*Lock]provision.Credentials)
+	credentials := source.Store.AuthenticationCredentials()
+	credentials.Issuers = make(map[string]provision.Issuer)
+	for _, l := range locks {
+		snapshot := l.Store.AuthenticationCredentials()
+		snapshots[l] = snapshot
+		if snapshot.ReaderPrivateKey != credentials.ReaderPrivateKey {
+			continue
+		}
+		for iid, issuer := range snapshot.Issuers {
+			merged, ok := credentials.Issuers[iid]
+			if !ok {
+				merged = provision.Issuer{PublicKey: issuer.PublicKey, Endpoints: map[string]provision.Endpoint{}}
+			}
+			for eid, endpoint := range issuer.Endpoints {
+				if endpoint.KeyState != 1 {
+					continue
+				}
+				if existing, ok := merged.Endpoints[eid]; ok && existing.PublicKey != endpoint.PublicKey {
+					return fmt.Errorf("conflicting endpoint enrollment")
+				}
+				merged.Endpoints[eid] = endpoint
+			}
+			credentials.Issuers[iid] = merged
+		}
+	}
+	result, err := nfcauth.Authenticate(ctx, relay, credentials)
+	if err != nil {
+		return err
+	}
+	// Persistent keys depend on the identity used in AUTH0. Only cache on
+	// that identity's lock, and only if it enrolled the authenticated endpoint.
+	sourceSnapshot := snapshots[source]
+	if issuer, ok := sourceSnapshot.Issuers[result.IssuerID]; ok {
+		if ep, ok := issuer.Endpoints[result.EndpointID]; ok && ep.KeyState == 1 {
+			if err = source.Store.SaveAuthenticatedKey(sourceSnapshot, result.IssuerID, result.EndpointID, result.PublicKey, result.PersistentKey); err != nil {
+				return err
+			}
+		}
+	}
+	success = unlockAuthorized(ctx, locks, snapshots, credentials.ReaderPrivateKey, result, reader, card.Session) > 0
+	if !success {
+		return fmt.Errorf("no assigned lock authorizes this endpoint")
+	}
+	return nil
+}
+
+func unlockAuthorized(ctx context.Context, locks []*Lock, snapshots map[*Lock]provision.Credentials, groupKey string, result *nfcauth.Result, reader string, session uint32) int {
+	unlocked := 0
+	for _, l := range locks {
+		expected := snapshots[l]
+		if expected.ReaderPrivateKey != groupKey {
+			continue
+		}
+		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
+			}
+			return l.Device.Lock.LockCurrentState.SetValue(0)
+		})
+		if e != nil {
+			continue
+		}
+		unlocked++
+		log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s lock=%s; virtual lock unlocked", reader, session, result.EndpointID, l.Config.ID)
+	}
+	return unlocked
+}
diff --git a/internal/app/web.go b/internal/app/web.go
new file mode 100644
index 0000000..b3644a4
--- /dev/null
+++ b/internal/app/web.go
@@ -0,0 +1,92 @@
+package app
+
+import (
+	"encoding/json"
+	"net"
+	"net/http"
+	"strings"
+)
+
+// HA ingress authenticates the user. Only its gateway may reach this listener;
+// standalone mode binds to loopback instead.
+func Handler(locks []*Lock, readers []ReaderConfig, ingress bool) http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /api/locks", func(w http.ResponseWriter, r *http.Request) {
+		statuses := make([]LockStatus, 0, len(locks))
+		for _, l := range locks {
+			statuses = append(statuses, l.Status())
+		}
+		readerViews := make([]ReaderView, 0, len(readers))
+		for _, reader := range readers {
+			v := ReaderView{ReaderConfig: reader}
+			if _, err := Group(Assigned(locks, reader.ID)); err != nil {
+				v.Warning = err.Error()
+			}
+			readerViews = append(readerViews, v)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(struct {
+			Locks   []LockStatus `json:"locks"`
+			Readers []ReaderView `json:"readers"`
+		}{statuses, readerViews})
+	})
+	mux.HandleFunc("POST /api/locks/{id}/{action}", func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("X-Homekey-Action") != "pairing" {
+			http.Error(w, "missing action header", http.StatusForbidden)
+			return
+		}
+		for _, l := range locks {
+			if l.Config.ID == r.PathValue("id") {
+				switch r.PathValue("action") {
+				case "pair":
+					if err := l.Pair(); err != nil {
+						http.Error(w, err.Error(), http.StatusConflict)
+						return
+					}
+				case "cancel":
+					l.CancelPair()
+				default:
+					http.NotFound(w, r)
+					return
+				}
+				w.WriteHeader(http.StatusNoContent)
+				return
+			}
+		}
+		http.NotFound(w, r)
+	})
+	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		w.Write([]byte(page))
+	})
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		host, _, err := net.SplitHostPort(r.RemoteAddr)
+		if ingress && (err != nil || host != "172.30.32.2") {
+			http.Error(w, "ingress only", http.StatusForbidden)
+			return
+		}
+		if origin := r.Header.Get("Origin"); !ingress && origin != "" && !strings.HasSuffix(origin, "://"+r.Host) {
+			http.Error(w, "invalid origin", http.StatusForbidden)
+			return
+		}
+		w.Header().Set("Cache-Control", "no-store")
+		w.Header().Set("X-Content-Type-Options", "nosniff")
+		w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'self'")
+		mux.ServeHTTP(w, r)
+	})
+}
+
+type ReaderView struct {
+	ReaderConfig
+	Warning string `json:"warning,omitempty"`
+}
+
+const page = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home Key locks</title><style>
+body{font:16px system-ui;margin:24px auto;padding:0 20px;max-width:850px;color:#18222b;background:#f4f6f8}article{background:white;border:1px solid #ccd4dc;border-radius:10px;padding:20px;margin:16px 0}h2{margin-top:0}button{padding:10px 18px;margin-right:10px;cursor:pointer}code{font-size:24px}small{color:#52616e}.error{color:#a21d1d}
+</style></head><body><h1>Home Key locks</h1><p>Add or edit locks and readers in the app's Configuration tab, then restart. Keep each lock ID unchanged after pairing.</p><p>Pair opens a five-minute HomeKit setup window. Add the lock in Apple Home using the displayed code.</p><p id="error" class="error"></p><main id="locks"></main><h2>Readers</h2><div id="readers"></div><script>
+const apiBase=location.pathname+(location.pathname.endsWith("/")?"":"/");
+const el=(tag,text)=>{const n=document.createElement(tag);n.textContent=text;return n};
+async function action(id,type){try{const r=await fetch(apiBase+'api/locks/'+encodeURIComponent(id)+'/'+type,{method:'POST',headers:{'X-Homekey-Action':'pairing'}});if(!r.ok)throw Error(await r.text());await refresh()}catch(e){document.getElementById('error').textContent=e.message}}
+async function refresh(){try{const r=await fetch(apiBase+'api/locks');if(!r.ok)throw Error(await r.text());const data=await r.json();document.getElementById('error').textContent='';const main=document.getElementById('locks');main.replaceChildren();if(!data.locks.length)main.append(el('p','No locks configured. Add a lock in Configuration.'));for(const l of data.locks){const a=el('article','');a.append(el('h2',l.name),el('small','ID: '+l.id+' · HomeKit port: '+l.port),el('p','Readers: '+(l.readers||[]).join(', ')),el('p',l.summary.paired_controllers?'Paired':'Unpaired'));if(l.pin){a.append(el('code',l.pin),el('p','Pairing window ends '+new Date(l.until).toLocaleTimeString()));const b=el('button','Cancel pairing');b.onclick=()=>action(l.id,'cancel');a.append(b)}else if(!l.summary.paired_controllers){const b=el('button','Pair');b.onclick=()=>action(l.id,'pair');a.append(b)}main.append(a)}const readers=document.getElementById('readers');readers.replaceChildren();for(const r of data.readers)readers.append(el('p',r.id+' · '+r.address+' · '+r.adapter+(r.warning?' · '+r.warning:'')));}catch(e){document.getElementById('error').textContent=e.message}}
+refresh();setInterval(refresh,2000);
+</script></body></html>`