penguin/homekey-go

homekey-go

commit 67d95f035dc7ad81c40d815f2738c6e0d7197272

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-05T10:58:25Z
subjectMove configuration into Open Web UI
commit 67d95f035dc7ad81c40d815f2738c6e0d7197272
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-05T10:58:25Z

    Move configuration into Open Web UI
---
 README.md                             |  11 ++-
 build.env                             |   2 +-
 cmd/homekey/main.go                   | 102 ++++++++++++++++++++------
 ha-app/CHANGELOG.md                   |   9 +++
 ha-app/DOCS.md                        | 101 +++++++++++++------------
 ha-app/NFC.md                         |   4 +-
 ha-app/README.md                      |   2 +-
 ha-app/config.yaml                    |  26 +------
 ha-app/translations/en.yaml           |  11 +--
 internal/app/config.go                |  31 ++++++++
 internal/app/page.html                |  48 ++++++++++++
 internal/app/settings.go              | 111 ++++++++++++++++++++++++++++
 internal/app/settings_test.go         |  87 ++++++++++++++++++++++
 internal/app/web.go                   |  80 +++++++++++++++++---
 internal/blegateway/discovery.go      | 134 ++++++++++++++++++++++++++++++++++
 internal/blegateway/discovery_test.go |  34 +++++++++
 16 files changed, 670 insertions(+), 123 deletions(-)

diff --git a/README.md b/README.md
index 7f60849..06229aa 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,15 @@ 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.
 
-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).
+In HA OS, use **Open Web UI** for configuration and pairing. Readers and
+adapters are populated by Bluetooth discovery; lock reader assignments use
+checkboxes. Save configuration restarts connections automatically. Existing
+HA options are imported once into `state/config.json`. See [HA app instructions](ha-app/DOCS.md).
+
+The Web UI generates IDs and offers friendly names; no reader ID needs to be
+typed. Manual Bluetooth address entry remains available if discovery fails.
+Whitespace is trimmed before validation. Standalone UI edits save directly to
+the `-config` JSON file.
 
 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,
diff --git a/build.env b/build.env
index d1411a9..8d6d358 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.6
+IMAGE_TAG=0.0.7
 
 APT_PROXY=
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
index 605f61d..f73f26d 100644
--- a/cmd/homekey/main.go
+++ b/cmd/homekey/main.go
@@ -37,39 +37,63 @@ func run() error {
 	if flag.NArg() != 0 {
 		return fmt.Errorf("unexpected positional argument")
 	}
-	c, err := app.Load(*config)
-	if err != nil {
+
+	if err := os.MkdirAll(*state, 0700); err != nil {
 		return err
 	}
-	var ifaces []string
-	if c.Interface != "" {
-		if _, err := net.InterfaceByName(c.Interface); err != nil {
+	if err := os.Chmod(*state, 0700); err != nil {
+		return err
+	}
+	if !*status {
+		unlock, err := lockState(filepath.Join(*state, ".lock"))
+		if err != nil {
 			return err
 		}
-		ifaces = []string{c.Interface}
+		defer unlock()
 	}
-	if err = os.MkdirAll(*state, 0700); err != nil {
-		return err
+	activeConfig := *config
+	seed := ""
+	if *ingress {
+		activeConfig = filepath.Join(*state, "config.json")
+		seed = *config
 	}
-	if err = os.Chmod(*state, 0700); err != nil {
-		return err
+	processCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	for {
+		settings, err := app.OpenSettings(activeConfig, seed, *status)
+		if err != nil {
+			return err
+		}
+		err = serve(processCtx, *state, *webAddr, *ingress, *status, settings)
+		if !errors.Is(err, errReload) {
+			return err
+		}
+		if processCtx.Err() != nil {
+			return nil
+		}
 	}
-	if !*status {
-		unlock, e := lockState(filepath.Join(*state, ".lock"))
-		if e != nil {
-			return e
+}
+
+var errReload = errors.New("configuration reload")
+
+func serve(processCtx context.Context, state, webAddr string, ingress, status bool, settings *app.Settings) error {
+	c, _ := settings.Snapshot()
+	var ifaces []string
+	if c.Interface != "" {
+		if _, err := net.InterfaceByName(c.Interface); err != nil {
+			return err
 		}
-		defer unlock()
+		ifaces = []string{c.Interface}
 	}
 	var locks []*app.Lock
 	summaries := map[string]provision.Summary{}
 	for _, cfg := range c.Locks {
-		dir := filepath.Join(*state, "locks", cfg.ID)
+		dir := filepath.Join(state, "locks", cfg.ID)
 		store, e := provision.Open(dir)
 		if e != nil {
 			return e
 		}
-		if *status {
+		if status {
 			summaries[cfg.ID] = store.Summary()
 			continue
 		}
@@ -83,10 +107,10 @@ func run() error {
 		}
 		locks = append(locks, l)
 	}
-	if *status {
+	if status {
 		return json.NewEncoder(os.Stdout).Encode(summaries)
 	}
-	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	ctx, cancel := context.WithCancel(processCtx)
 	defer cancel()
 	var wg sync.WaitGroup
 	failures := make(chan error, len(locks)+1)
@@ -125,7 +149,36 @@ func run() error {
 			})
 		}(r, assigned)
 	}
-	web := &http.Server{Addr: *webAddr, Handler: app.Handler(locks, c.Readers, *ingress), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
+	settings.BeforeSave = func(next app.Config) error {
+		if next.Interface != "" {
+			if _, err := net.InterfaceByName(next.Interface); err != nil {
+				return err
+			}
+		}
+		oldPorts := map[int]bool{}
+		for _, l := range c.Locks {
+			oldPorts[l.Port] = true
+		}
+		for _, l := range next.Locks {
+			if !oldPorts[l.Port] {
+				listener, err := net.Listen("tcp", fmt.Sprintf(":%d", l.Port))
+				if err != nil {
+					return fmt.Errorf("HomeKit port %d is unavailable: %w", l.Port, err)
+				}
+				listener.Close()
+			}
+		}
+		return nil
+	}
+	reload := make(chan struct{}, 1)
+	settings.Reload = func() {
+		select {
+		case reload <- struct{}{}:
+		default:
+		}
+		cancel()
+	}
+	web := &http.Server{Addr: webAddr, Handler: app.Handler(locks, c.Readers, ingress, settings), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
 	wg.Add(1)
 	go func() {
 		defer wg.Done()
@@ -134,7 +187,7 @@ func run() error {
 			cancel()
 		}
 	}()
-	log.Printf("Configured %d locks and %d readers; pairing UI at %s", len(locks), len(c.Readers), *webAddr)
+	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()
@@ -144,6 +197,11 @@ func run() error {
 	case err := <-failures:
 		return err
 	default:
-		return nil
+		select {
+		case <-reload:
+			return errReload
+		default:
+			return nil
+		}
 	}
 }
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index 8dedcbc..23c42e0 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,12 @@
+## 0.0.7
+
+- Move configuration into Open Web UI, importing existing HA options once.
+- Discover compatible Home Key Bluetooth readers and local adapters.
+- Select reader devices from populated dropdowns and lock assignments via checkboxes.
+- Generate immutable IDs automatically; give readers editable friendly names.
+- Trim configuration whitespace and normalize Bluetooth addresses.
+- Save atomically, reject conflicting edits, and reload HomeKit/BLE connections.
+
 ## 0.0.6
 
 - Configure lock and reader lists; remove single-lock startup options.
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index fff56e6..55979e5 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -2,40 +2,54 @@
 
 ## 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
-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
-```
-
-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.
+Open **Open Web UI**. HA's static Configuration form has been replaced by the
+Go configuration editor; it cannot populate dynamic Bluetooth choices.
+
+1. Click **Add reader**. Bluetooth discovery lists Home Key readers with their
+   names, addresses, and adapters. Choose a device; its adapter is filled in.
+2. Give the reader a friendly name. Its immutable ID is generated automatically.
+3. Click **Add lock** or **Edit** beside an existing lock. Select its readers
+   using checkboxes; no IDs need to be typed. A lock can use several readers,
+   and a reader can be shared by several locks in the same Home Key group.
+4. Click **Save configuration**. The service saves atomically and briefly
+   restarts its HomeKit/BLE connections. The page reconnects automatically.
+   Existing lock IDs, pairing identities and credentials stay unchanged.
+
+**Scan Bluetooth** refreshes discovery. Only devices advertising the relay
+service, already exposing its GATT service, or previously configured readers
+are listed. Power on the ESP32 reader and local Bluetooth adapter first.
+Discovery does not connect to, pair with, or disconnect devices, and it does
+not change adapter settings. An off adapter is shown as off.
+
+If discovery is unavailable, **Enter address manually** remains available.
+Adapter choices use detected adapters and existing configured adapters; when
+none are known, the default hci0 is shown explicitly as unverified. Addresses
+and IDs are trimmed before validation, and addresses are normalized to uppercase.
+
+Names can be edited without changing IDs. HomeKit port and Wallet artwork are
+under **HomeKit settings**; the port defaults to the next free configured
+number. Every lock needs a unique port, and 8099 is reserved for the Web UI.
+New ports are checked for conflicts before saving. **Network settings** allows
+LAN interface selection; leave it empty for automatic selection.
+
+Removing a reader removes its draft lock assignments. Removing a lock stops
+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.
+
+## Updating from 0.0.6
+
+The first start imports existing locks, reader IDs, addresses, adapters and
+assignments from `/data/options.json` into `/data/state/config.json`.
+Subsequent starts load that saved configuration; Supervisor options do not
+replace it. Keep all of `/data/state` in backups. Reader names initially use
+existing IDs until renamed in the UI. Pairing state remains in its existing
+`/data/state/locks/<id>` directory.
+
+Fresh installations start empty. Add readers and locks through Open Web UI.
+The current ESP32 firmware advertises one Home Key ECP group; shared readers
+must serve locks in the same group, normally the same Apple Home. Different
+groups reject taps. Only assigned locks with active enrollment unlock.
 
 ## Pairing
 
@@ -51,29 +65,12 @@ 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
+matching the version in `config.yaml`; publish the 0.0.7 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
diff --git a/ha-app/NFC.md b/ha-app/NFC.md
index d9fa2d5..41e48e3 100644
--- a/ha-app/NFC.md
+++ b/ha-app/NFC.md
@@ -24,8 +24,8 @@ 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. 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,
+3. Discover readers and assign them to locks in Open Web UI as described in
+   [DOCS.md](DOCS.md), then save the configuration. 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
diff --git a/ha-app/README.md b/ha-app/README.md
index 051d9a7..90fd50b 100644
--- a/ha-app/README.md
+++ b/ha-app/README.md
@@ -1,7 +1,7 @@
 # Home Key Go
 
 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.
+Use Open Web UI to discover readers, configure locks, assign readers and pair.
 One reader can serve several locks in the same Home Key group.
 
 See [DOCS.md](DOCS.md) for setup and migration. Physical locks and HA entity
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index 4f0ecfd..11f8caf 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.6"
+version: "0.0.7"
 slug: homekey_go
 description: HomeKit virtual lock with Apple Home Key NFC authentication
 url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/
@@ -17,25 +17,5 @@ image: ghcr.io/tgckpg/homekey-go
 ingress: true
 ingress_port: 8099
 panel_title: Home Key locks
-options:
-  interface: ""
-  locks:
-    - id: GO-HOMEKEY-001
-      name: Go Home Key
-      port: 51826
-      finish: silver
-      readers: []
-  readers: []
-schema:
-  interface: 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
+options: {}
+schema: false
diff --git a/ha-app/translations/en.yaml b/ha-app/translations/en.yaml
index 09e3dff..0967ef4 100644
--- a/ha-app/translations/en.yaml
+++ b/ha-app/translations/en.yaml
@@ -1,10 +1 @@
-configuration:
-  interface:
-    name: LAN interface
-    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/config.go b/internal/app/config.go
index 7ef491b..f64a66a 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -18,6 +18,7 @@ type LockConfig struct {
 }
 type ReaderConfig struct {
 	ID      string `json:"id"`
+	Name    string `json:"name,omitempty"`
 	Address string `json:"address"`
 	Adapter string `json:"adapter"`
 }
@@ -39,6 +40,7 @@ func Load(path string) (Config, error) {
 	if err = json.Unmarshal(b, &c); err != nil {
 		return c, err
 	}
+	c.Normalize()
 	return c, c.Validate()
 }
 func (c Config) Validate() error {
@@ -90,3 +92,32 @@ func (c Config) Validate() error {
 	}
 	return nil
 }
+
+func (c *Config) Normalize() {
+	c.Interface = strings.TrimSpace(c.Interface)
+	if c.Locks == nil {
+		c.Locks = []LockConfig{}
+	}
+	if c.Readers == nil {
+		c.Readers = []ReaderConfig{}
+	}
+	for i := range c.Readers {
+		r := &c.Readers[i]
+		r.ID = strings.TrimSpace(r.ID)
+		r.Name = strings.TrimSpace(r.Name)
+		r.Address = strings.ToUpper(strings.TrimSpace(r.Address))
+		r.Adapter = strings.TrimSpace(r.Adapter)
+	}
+	for i := range c.Locks {
+		l := &c.Locks[i]
+		l.ID = strings.TrimSpace(l.ID)
+		l.Name = strings.TrimSpace(l.Name)
+		l.Finish = strings.TrimSpace(l.Finish)
+		if l.Readers == nil {
+			l.Readers = []string{}
+		}
+		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
new file mode 100644
index 0000000..c98a4a1
--- /dev/null
+++ b/internal/app/page.html
@@ -0,0 +1,48 @@
+<!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>
+: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>
+<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>
+<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="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>
+<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;
+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()}));
+  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 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()};
+$('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()}
+function selectDevice(){const value=$('reader-device').value,manual=value==='manual';$('manual-address-label').hidden=!manual;if(!manual){const [adapter,address]=value.split('|');$('reader-address').value=address;$('reader-adapter').value=adapter;const d=discovery.readers.find(x=>x.address===address&&x.adapter===adapter);if(!editingReader&&d)$('reader-name').value=d.name}$('reader-adapter').disabled=!manual}
+function openReader(id=null){editingReader=id;const r=config.readers.find(x=>x.id===id);$('reader-title').textContent=r?'Edit reader':'Add reader';$('reader-name').value=r?label(r):'';$('reader-address').value=r?.address||'';$('reader-id').textContent=r?'Stable ID: '+r.id:'A stable ID will be generated automatically.';$('reader-error').textContent='';$('reader-device').replaceChildren();$('reader-adapter').replaceChildren();readerChoices();$('reader-dialog').showModal();if(!discovery.adapters.length&&!scanning)scan()}
+$('reader-device').onchange=selectDevice;
+$('reader-form').onsubmit=e=>{e.preventDefault();const address=$('reader-address').value.trim().toUpperCase(),adapter=$('reader-adapter').value,name=$('reader-name').value.trim();if(!/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(address)){ $('reader-error').textContent='Enter a six-octet Bluetooth address, for example 70:AF:09:16:42:5A.';return}if(!name||!adapter){$('reader-error').textContent='Choose an adapter and enter a reader name.';return}if(config.readers.some(r=>r.id!==editingReader&&r.address===address)){$('reader-error').textContent='This reader is already configured.';return}const id=editingReader||'reader-'+address.replaceAll(':','').toLowerCase(),r={id,name,address,adapter};if(editingReader)config.readers=config.readers.map(x=>x.id===id?r:x);else config.readers.push(r);$('reader-dialog').close();markDirty()};
+$('add-reader').onclick=()=>openReader();$('cancel-reader').onclick=()=>$('reader-dialog').close();
+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)}};
+load().then(()=>scan()).catch(e=>message(e.message,true));setInterval(()=>{if(!restarting)refresh()},2000);
+</script></body></html>
diff --git a/internal/app/settings.go b/internal/app/settings.go
new file mode 100644
index 0000000..e314834
--- /dev/null
+++ b/internal/app/settings.go
@@ -0,0 +1,111 @@
+package app
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+)
+
+var ErrConflict = errors.New("configuration changed; reload the page before saving")
+var ErrReloading = errors.New("configuration is restarting; retry after the page reconnects")
+
+type Settings struct {
+	mu         sync.Mutex
+	path       string
+	config     Config
+	restarting bool
+	BeforeSave func(Config) error
+	Reload     func()
+}
+
+func OpenSettings(path, seed string, readOnly ...bool) (*Settings, error) {
+	c, err := Load(path)
+	if errors.Is(err, os.ErrNotExist) && seed != "" {
+		c, err = Load(seed)
+		if err != nil {
+			return nil, err
+		}
+		if len(readOnly) == 0 || !readOnly[0] {
+			if err = writeConfig(path, c); err != nil {
+				return nil, err
+			}
+		}
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &Settings{path: path, config: c}, nil
+}
+func revision(c Config) string {
+	b, _ := json.Marshal(c)
+	sum := sha256.Sum256(b)
+	return hex.EncodeToString(sum[:])
+}
+func (s *Settings) Snapshot() (Config, string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	b, _ := json.Marshal(s.config)
+	var c Config
+	_ = json.Unmarshal(b, &c)
+	return c, revision(c)
+}
+func (s *Settings) Save(c Config, expected string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.restarting {
+		return ErrReloading
+	}
+	if expected != revision(s.config) {
+		return ErrConflict
+	}
+	c.Normalize()
+	if err := c.Validate(); err != nil {
+		return err
+	}
+	if s.BeforeSave != nil {
+		if err := s.BeforeSave(c); err != nil {
+			return err
+		}
+	}
+	if err := writeConfig(s.path, c); err != nil {
+		return err
+	}
+	s.config = c
+	s.restarting = true
+	return nil
+}
+func writeConfig(path string, c Config) error {
+	b, err := json.MarshalIndent(c, "", "  ")
+	if err != nil {
+		return err
+	}
+	f, err := os.CreateTemp(filepath.Dir(path), ".config-*")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(f.Name())
+	if _, err = f.Write(append(b, '\n')); err != nil {
+		f.Close()
+		return err
+	}
+	if err = f.Sync(); err != nil {
+		f.Close()
+		return err
+	}
+	if err = f.Close(); err != nil {
+		return err
+	}
+	if err = os.Rename(f.Name(), path); err != nil {
+		return fmt.Errorf("save configuration: %w", err)
+	}
+	if dir, e := os.Open(filepath.Dir(path)); e == nil {
+		_ = dir.Sync()
+		_ = dir.Close()
+	}
+	return nil
+}
diff --git a/internal/app/settings_test.go b/internal/app/settings_test.go
new file mode 100644
index 0000000..1270a11
--- /dev/null
+++ b/internal/app/settings_test.go
@@ -0,0 +1,87 @@
+package app
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestSettingsImportNormalizeAndConflict(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)
+	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)
+	if err != nil {
+		t.Fatal(err)
+	}
+	c, rev := s.Snapshot()
+	if c.Readers[0].Address != "70:AF:09:16:42:5A" || c.Readers[0].Adapter != "hci0" || c.Locks[0].Readers[0] != "TestReader01" {
+		t.Fatalf("whitespace not normalized: %+v", c)
+	}
+	if err = s.Save(c, "stale"); !errors.Is(err, ErrConflict) {
+		t.Fatal("stale revision accepted")
+	}
+	c.Readers[0].Name = "Porch"
+	if err = s.Save(c, rev); err != nil {
+		t.Fatal(err)
+	}
+	if err = s.Save(c, rev); !errors.Is(err, ErrReloading) {
+		t.Fatal("second save accepted during reload")
+	}
+	os.WriteFile(seed, []byte(`{"locks":[],"readers":[]}`), 0600)
+	reopened, err := OpenSettings(path, seed)
+	if err != nil {
+		t.Fatal(err)
+	}
+	c, _ = reopened.Snapshot()
+	if len(c.Readers) != 1 || c.Readers[0].Name != "Porch" {
+		t.Fatal("Supervisor seed overwrote saved settings")
+	}
+	info, _ := os.Stat(path)
+	if info.Mode().Perm() != 0600 {
+		t.Fatal("unsafe config permissions")
+	}
+}
+func TestConfigurationHTTPValidation(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "config.json")
+	writeConfig(path, Config{Locks: []LockConfig{}, Readers: []ReaderConfig{}})
+	s, _ := OpenSettings(path, "")
+	calls := 0
+	s.Reload = func() { calls++ }
+	handler := Handler(nil, nil, false, s)
+	c, rev := s.Snapshot()
+	send := func(header, revision string, c Config) *httptest.ResponseRecorder {
+		body, _ := json.Marshal(map[string]any{"config": c, "revision": revision})
+		r := httptest.NewRequest("POST", "/api/config", bytes.NewReader(body))
+		r.Header.Set("X-Homekey-Action", header)
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+		return w
+	}
+	if w := send("", rev, c); w.Code != 403 {
+		t.Fatal("CSRF action header not enforced")
+	}
+	if w := send("configuration", "stale", c); w.Code != 409 {
+		t.Fatal("stale settings not rejected")
+	}
+	c.Locks = append(c.Locks, LockConfig{ID: "front", Name: "Front", Port: 51826, Finish: "silver", Readers: []string{"missing"}})
+	if w := send("configuration", rev, c); w.Code != 400 || calls != 0 {
+		t.Fatal("invalid settings restarted service")
+	}
+	c.Locks[0].Readers = []string{}
+	if w := send("configuration", rev, c); w.Code != 202 || calls != 1 {
+		t.Fatalf("save failed %d %s", w.Code, w.Body.String())
+	}
+}
diff --git a/internal/app/web.go b/internal/app/web.go
index b3644a4..5d0a6a7 100644
--- a/internal/app/web.go
+++ b/internal/app/web.go
@@ -1,16 +1,83 @@
 package app
 
 import (
+	"context"
+	_ "embed"
 	"encoding/json"
+	"errors"
+	"homekey.local/provisioner/internal/blegateway"
+	"io"
 	"net"
 	"net/http"
 	"strings"
+	"time"
 )
 
 // 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 {
+func Handler(locks []*Lock, readers []ReaderConfig, ingress bool, settings ...*Settings) http.Handler {
 	mux := http.NewServeMux()
+	if len(settings) > 0 && settings[0] != nil {
+		cfg := settings[0]
+		mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
+			c, rev := cfg.Snapshot()
+			w.Header().Set("Content-Type", "application/json")
+			json.NewEncoder(w).Encode(struct {
+				Config   Config `json:"config"`
+				Revision string `json:"revision"`
+			}{c, rev})
+		})
+		mux.HandleFunc("POST /api/config", func(w http.ResponseWriter, r *http.Request) {
+			if r.Header.Get("X-Homekey-Action") != "configuration" {
+				http.Error(w, "missing action header", 403)
+				return
+			}
+			var payload struct {
+				Config   Config `json:"config"`
+				Revision string `json:"revision"`
+			}
+			r.Body = http.MaxBytesReader(w, r.Body, 128*1024)
+			decoder := json.NewDecoder(r.Body)
+			decoder.DisallowUnknownFields()
+			if err := decoder.Decode(&payload); err != nil {
+				http.Error(w, "invalid configuration JSON", 400)
+				return
+			}
+			var extra any
+			if err := decoder.Decode(&extra); err != io.EOF {
+				http.Error(w, "trailing configuration data", 400)
+				return
+			}
+			if err := cfg.Save(payload.Config, payload.Revision); err != nil {
+				code := 400
+				if errors.Is(err, ErrConflict) || errors.Is(err, ErrReloading) {
+					code = 409
+				}
+				http.Error(w, err.Error(), code)
+				return
+			}
+			w.WriteHeader(http.StatusAccepted)
+			if cfg.Reload != nil {
+				cfg.Reload()
+			}
+		})
+		mux.HandleFunc("GET /api/discovery", func(w http.ResponseWriter, r *http.Request) {
+			ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
+			defer cancel()
+			c, _ := cfg.Snapshot()
+			known := make([]string, 0, len(c.Readers))
+			for _, r := range c.Readers {
+				known = append(known, r.Address)
+			}
+			d, err := blegateway.Discover(ctx, known)
+			if err != nil {
+				http.Error(w, err.Error(), 503)
+				return
+			}
+			w.Header().Set("Content-Type", "application/json")
+			json.NewEncoder(w).Encode(d)
+		})
+	}
 	mux.HandleFunc("GET /api/locks", func(w http.ResponseWriter, r *http.Request) {
 		statuses := make([]LockStatus, 0, len(locks))
 		for _, l := range locks {
@@ -81,12 +148,5 @@ type ReaderView struct {
 	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>`
+//go:embed page.html
+var page string
diff --git a/internal/blegateway/discovery.go b/internal/blegateway/discovery.go
new file mode 100644
index 0000000..daa1bf0
--- /dev/null
+++ b/internal/blegateway/discovery.go
@@ -0,0 +1,134 @@
+package blegateway
+
+import (
+	"context"
+	"fmt"
+	"github.com/godbus/dbus/v5"
+	"sort"
+	"strings"
+	"time"
+)
+
+type AdapterInfo struct {
+	ID      string `json:"id"`
+	Name    string `json:"name"`
+	Address string `json:"address"`
+	Powered bool   `json:"powered"`
+}
+type ReaderInfo struct {
+	Name    string `json:"name"`
+	Address string `json:"address"`
+	Adapter string `json:"adapter"`
+}
+type Discovery struct {
+	Adapters []AdapterInfo `json:"adapters"`
+	Readers  []ReaderInfo  `json:"readers"`
+	Warnings []string      `json:"warnings"`
+}
+
+func discoverySnapshot(o objects, known map[string]bool) Discovery {
+	d := Discovery{Adapters: []AdapterInfo{}, Readers: []ReaderInfo{}, Warnings: []string{}}
+	services := map[dbus.ObjectPath]bool{}
+	for _, ifs := range o {
+		p, ok := ifs["org.bluez.GattService1"]
+		if ok && strings.EqualFold(text(p, "UUID"), ServiceUUID) {
+			owner, _ := p["Device"].Value().(dbus.ObjectPath)
+			services[owner] = true
+		}
+	}
+	for path, ifs := range o {
+		if p, ok := ifs["org.bluez.Adapter1"]; ok {
+			powered, _ := p["Powered"].Value().(bool)
+			name := text(p, "Alias")
+			if name == "" {
+				name = text(p, "Name")
+			}
+			id := string(path)
+			id = id[strings.LastIndex(id, "/")+1:]
+			d.Adapters = append(d.Adapters, AdapterInfo{ID: id, Name: name, Address: text(p, "Address"), Powered: powered})
+		}
+		if p, ok := ifs["org.bluez.Device1"]; ok {
+			address := strings.ToUpper(text(p, "Address"))
+			match := known[address] || services[path]
+			uuids, _ := p["UUIDs"].Value().([]string)
+			for _, u := range uuids {
+				if strings.EqualFold(u, ServiceUUID) {
+					match = true
+				}
+			}
+			if !match {
+				continue
+			}
+			adapter, _ := p["Adapter"].Value().(dbus.ObjectPath)
+			id := string(adapter)
+			id = id[strings.LastIndex(id, "/")+1:]
+			name := text(p, "Alias")
+			if name == "" {
+				name = text(p, "Name")
+			}
+			if name == "" {
+				name = "Home Key reader"
+			}
+			d.Readers = append(d.Readers, ReaderInfo{Name: name, Address: address, Adapter: id})
+		}
+	}
+	sort.Slice(d.Adapters, func(i, j int) bool { return d.Adapters[i].ID < d.Adapters[j].ID })
+	sort.Slice(d.Readers, func(i, j int) bool {
+		a, b := d.Readers[i], d.Readers[j]
+		return a.Adapter+a.Address < b.Adapter+b.Address
+	})
+	return d
+}
+
+// Discover uses a separate BlueZ connection and owns only its own discovery
+// sessions. It never pairs, connects, disconnects, or changes adapter settings.
+func Discover(ctx context.Context, known []string) (Discovery, error) {
+	c, err := dbus.ConnectSystemBus()
+	if err != nil {
+		return Discovery{}, fmt.Errorf("Bluetooth unavailable: %w", err)
+	}
+	defer c.Close()
+	o, err := getObjects(ctx, c)
+	if err != nil {
+		return Discovery{}, err
+	}
+	addresses := map[string]bool{}
+	for _, a := range known {
+		addresses[strings.ToUpper(strings.TrimSpace(a))] = true
+	}
+	initial := discoverySnapshot(o, addresses)
+	var scans []dbus.BusObject
+	warnings := []string{}
+	defer func() {
+		cleanup, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+		defer cancel()
+		for _, a := range scans {
+			_ = a.CallWithContext(cleanup, "org.bluez.Adapter1.StopDiscovery", 0).Err
+		}
+	}()
+	for _, a := range initial.Adapters {
+		if !a.Powered {
+			warnings = append(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 {
+			warnings = append(warnings, a.ID+": "+e.Error())
+			continue
+		}
+		scans = append(scans, obj)
+	}
+	if len(scans) > 0 && !wait(ctx, 5*time.Second) {
+		return Discovery{}, ctx.Err()
+	}
+	o, err = getObjects(ctx, c)
+	if err != nil {
+		return Discovery{}, err
+	}
+	d := discoverySnapshot(o, addresses)
+	d.Warnings = warnings
+	if len(d.Adapters) == 0 {
+		d.Warnings = append(d.Warnings, "No local Bluetooth adapters found")
+	}
+	return d, nil
+}
diff --git a/internal/blegateway/discovery_test.go b/internal/blegateway/discovery_test.go
new file mode 100644
index 0000000..1ac7480
--- /dev/null
+++ b/internal/blegateway/discovery_test.go
@@ -0,0 +1,34 @@
+package blegateway
+
+import (
+	"github.com/godbus/dbus/v5"
+	"testing"
+)
+
+func TestDiscoveryFiltersReadersAndFindsAdapters(t *testing.T) {
+	props := func(values map[string]any) map[string]dbus.Variant {
+		p := map[string]dbus.Variant{}
+		for k, v := range values {
+			p[k] = dbus.MakeVariant(v)
+		}
+		return p
+	}
+	o := objects{
+		"/org/bluez/hci1":                {"org.bluez.Adapter1": props(map[string]any{"Alias": "USB Bluetooth", "Address": "AA:00:00:00:00:01", "Powered": true})},
+		"/org/bluez/hci0":                {"org.bluez.Adapter1": props(map[string]any{"Alias": "Internal", "Powered": false})},
+		"/org/bluez/hci1/dev_reader":     {"org.bluez.Device1": props(map[string]any{"Name": "Test reader", "Address": "70:af:09:16:42:5a", "Adapter": dbus.ObjectPath("/org/bluez/hci1"), "UUIDs": []string{ServiceUUID}})},
+		"/org/bluez/hci1/dev_headphones": {"org.bluez.Device1": props(map[string]any{"Name": "Headphones", "Address": "11:22:33:44:55:66", "Adapter": dbus.ObjectPath("/org/bluez/hci1")})},
+		"/org/bluez/hci1/dev_known":      {"org.bluez.Device1": props(map[string]any{"Alias": "Configured reader", "Address": "AA:BB:CC:DD:EE:FF", "Adapter": dbus.ObjectPath("/org/bluez/hci1")})},
+	}
+	d := discoverySnapshot(o, map[string]bool{"AA:BB:CC:DD:EE:FF": true})
+	if len(d.Adapters) != 2 || d.Adapters[0].ID != "hci0" || !d.Adapters[1].Powered {
+		t.Fatalf("bad adapters: %+v", d.Adapters)
+	}
+	if len(d.Readers) != 2 || d.Readers[0].Address != "70:AF:09:16:42:5A" || d.Readers[0].Adapter != "hci1" {
+		t.Fatalf("bad readers: %+v", d.Readers)
+	}
+	o["/org/bluez/hci1/service"] = map[string]map[string]dbus.Variant{"org.bluez.GattService1": props(map[string]any{"UUID": ServiceUUID, "Device": dbus.ObjectPath("/org/bluez/hci1/dev_known")})}
+	if d := discoverySnapshot(o, nil); len(d.Readers) != 2 {
+		t.Fatal("known GATT service not detected")
+	}
+}