commit 67d95f035dc7ad81c40d815f2738c6e0d7197272
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-05T10:58:25Z |
| subject | Move 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")
+ }
+}