commit e617df408004ea36016b34622e8a37052dce94c5
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-07T12:57:32Z |
| subject | Added missing remove lock button |
commit e617df408004ea36016b34622e8a37052dce94c5
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-07T12:57:32Z
Added missing remove lock button
---
build.env | 2 +-
ha-app/CHANGELOG.md | 4 ++
ha-app/DOCS.md | 6 +-
ha-app/config.yaml | 2 +-
internal/app/page.html | 14 ++++-
internal/app/physical.go | 16 ++++++
internal/app/physical_test.go | 61 ++++++++++++++++++++
internal/app/settings.go | 22 ++++++++
internal/physical/manager.go | 114 ++++++++++++++++++++++++++++++++++----
internal/physical/manager_test.go | 96 ++++++++++++++++++++++++++++++++
10 files changed, 320 insertions(+), 17 deletions(-)
diff --git a/build.env b/build.env
index 9f0146f..a140b21 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.8
+IMAGE_TAG=0.0.9
APT_PROXY=
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index c1e0fed..c277c46 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.0.9
+
+- Fixed missing remove lock button
+
## 0.0.8
- Sesame: Prefers fresh advertisements on reconnect
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index 2723942..4037d06 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -73,7 +73,11 @@ The physical registry is `/data/state/physical-locks.json`; private keys are und
`/data/state/physical-lock-keys/` with restrictive permissions. Back up the entire
state directory. Keys are excluded from public configuration/status responses.
If enrollment saved a key but failed to save registry metadata, retry adding the
-same device to recover it. No reset or physical-lock key deletion is exposed.
+same device to recover it. To remove a physical lock, first unassign it from all Home Key locks and save
+configuration, then click **Remove** in Physical locks. Removal stops its BLE
+connection and deletes its local saved key immediately. The Sesame lock is not
+reset and its calibration is unchanged; adding it again requires a usable key
+or resetting and registering the lock again.
## Updating from 0.0.6
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index 776617f..de0c9ec 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
name: Home Key Go
-version: "0.0.8"
+version: "0.0.9"
slug: homekey_go
description: HomeKit virtual lock with Apple Home Key NFC authentication
url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/
diff --git a/internal/app/page.html b/internal/app/page.html
index f8780cc..0132aed 100644
--- a/internal/app/page.html
+++ b/internal/app/page.html
@@ -21,11 +21,11 @@ const apiBase=location.pathname+(location.pathname.endsWith('/')?'':'/');
const el=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};
const button=(text,fn)=>{const b=el('button',text);b.type='button';b.onclick=fn;return b};
let config={locks:[],readers:[],interface:''},revision='',status=[],discovery={adapters:[],readers:[]},dirty=false,restarting=false,scanning=false,editingLock=null,editingReader=null;
-let physicalLocks=[],physicalDiscovery={locks:[],adapters:[],warnings:[]},physicalScanning=false,physicalAdding=false,calibrating=null,calibrationBusy=false;
+let physicalLocks=[],physicalDiscovery={locks:[],adapters:[],warnings:[]},physicalScanning=false,physicalAdding=false,calibrating=null,calibrationBusy=false,physicalRemoving=false;
const label=r=>r.name||r.id;
function message(text,error=false){$('message').textContent=text;$('message').className='notice'+(error?' error':'')}
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 markDirty(){dirty=true;$('save').disabled=false;message('Unsaved changes. Save configuration to apply them.');render();renderPhysical()}
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')));a.append(el('p','Physical locks: '+((l.physical_locks||[]).map(id=>physicalLocks.find(p=>p.id===id)?.name||id).join(', ')||'None (simulation)')));const paired=!!s?.summary.paired_controllers;a.append(el('span',s?(paired?'Paired':'Unpaired'):'Pending save','badge'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openLock(l.id)),button('Remove',()=>{config.locks=config.locks.filter(x=>x.id!==l.id);markDirty()}));
@@ -52,7 +52,15 @@ $('save').onclick=async()=>{if(restarting)return;restarting=true;$('save').disab
function renderPhysical(){
const list=$('physical-locks');list.replaceChildren();if(!physicalLocks.length)list.append(el('p','No physical locks added.','empty'));
- for(const p of physicalLocks){const row=el('div','','reader'),info=el('div',''),state=p.state;info.append(el('strong',p.name),el('p',p.target.model+' · '+p.target.adapter,'muted'));info.append(el('p',physicalState(state),state.online?'muted':'error'));const actions=el('div','','toolbar');actions.append(button('Set positions',()=>openCalibration(p.id)));row.append(info,actions);list.append(row)}
+ for(const p of physicalLocks){const row=el('div','','reader'),info=el('div',''),state=p.state;info.append(el('strong',p.name),el('p',p.target.model+' · '+p.target.adapter,'muted'));info.append(el('p',physicalState(state),state.online?'muted':'error'));const actions=el('div','','toolbar');actions.append(button('Set positions',()=>openCalibration(p.id)));const remove=button('Remove',()=>removePhysical(p));remove.disabled=dirty||restarting||physicalRemoving;actions.append(remove);row.append(info,actions);list.append(row)}
+}
+async function removePhysical(p){
+ if(physicalRemoving||restarting)return;
+ if(dirty){message('Save your configuration changes before removing a physical lock.',true);return}
+ if(!confirm('Remove '+p.name+' from Go? Its saved key will be deleted. Sesame will not be reset. Adding it again requires its key or a reset.'))return;
+ physicalRemoving=true;renderPhysical();
+ try{await request('api/physical-locks/'+encodeURIComponent(p.id),{method:'DELETE',headers:{'X-Homekey-Action':'physical-lock'}});if(calibrating===p.id){$('calibration-dialog').close();calibrating=null}await refresh();message('Physical lock removed.')}
+ catch(e){message(e.message,true)}finally{physicalRemoving=false;renderPhysical()}
}
function physicalState(s){if(!s.online)return s.error||'Connecting…';if(s.position===undefined)return 'Connected; waiting for position';if(s.critical)return 'Lock reports a critical condition';if(!s.stopped)return 'Moving · '+s.position+'°';return (s.locked?'Locked':'Unlocked')+' · '+s.position+'°'+(s.battery_critical?' · Low battery':'')}
function physicalChoices(){const old=$('physical-device').value,available=physicalDiscovery.locks.filter(t=>!physicalLocks.some(p=>p.target.uuid===t.uuid));options($('physical-device'),available.map(t=>({value:t.adapter+'|'+t.uuid,label:t.model+' · '+t.address+' · '+t.adapter+(t.registered?' · Registered':' · Unregistered')})),old);selectPhysical()}
diff --git a/internal/app/physical.go b/internal/app/physical.go
index 59e7a68..f2b87fa 100644
--- a/internal/app/physical.go
+++ b/internal/app/physical.go
@@ -3,6 +3,7 @@ package app
import (
"context"
"encoding/json"
+ "errors"
"io"
"net/http"
"time"
@@ -95,6 +96,21 @@ func physicalRoutes(mux *http.ServeMux, cfg *Settings) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(d)
})
+ mux.HandleFunc("DELETE /api/physical-locks/{id}", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Homekey-Action") != "physical-lock" {
+ http.Error(w, "missing action header", 403)
+ return
+ }
+ if e := cfg.RemovePhysical(r.PathValue("id")); e != nil {
+ code := http.StatusConflict
+ if errors.Is(e, physical.ErrNotFound) {
+ code = http.StatusNotFound
+ }
+ http.Error(w, e.Error(), code)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ })
mux.HandleFunc("POST /api/physical-locks", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Homekey-Action") != "physical-lock" {
http.Error(w, "missing action header", 403)
diff --git a/internal/app/physical_test.go b/internal/app/physical_test.go
index 37d0744..f79051d 100644
--- a/internal/app/physical_test.go
+++ b/internal/app/physical_test.go
@@ -2,7 +2,9 @@ package app
import (
"context"
+ "encoding/json"
"net/http/httptest"
+ "os"
"path/filepath"
"strings"
"testing"
@@ -73,3 +75,62 @@ func TestAggregatePhysicalState(t *testing.T) {
t.Fatal("moving state must be unknown")
}
}
+
+func TestRemovePhysicalHTTPRejectsAssignmentsAndDeletesKey(t *testing.T) {
+ dir := t.TempDir()
+ target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Registered: true}
+ id := "sesame-" + target.UUID
+ keyDir := filepath.Join(dir, "physical-lock-keys")
+ os.Mkdir(keyDir, 0700)
+ key := filepath.Join(keyDir, id+".json")
+ os.WriteFile(key, []byte(`{"secret":"000102030405060708090a0b0c0d0e0f"}`), 0600)
+ raw, _ := json.Marshal([]physical.Record{{ID: id, Name: "Door", Driver: "sesame", Target: target}})
+ os.WriteFile(filepath.Join(dir, "physical-locks.json"), raw, 0600)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ m, e := physical.Open(ctx, dir, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer m.Close()
+ path := filepath.Join(t.TempDir(), "config.json")
+ cfg := Config{Locks: []LockConfig{{ID: "front", Name: "Front", Port: 51826, Finish: "silver", PhysicalLocks: []string{id}}}}
+ writeConfig(path, cfg)
+ s, e := OpenSettings(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ s.Physical = m
+ send := func(header string) int {
+ r := httptest.NewRequest("DELETE", "/api/physical-locks/"+id, nil)
+ r.Header.Set("X-Homekey-Action", header)
+ w := httptest.NewRecorder()
+ Handler(nil, nil, false, s).ServeHTTP(w, r)
+ return w.Code
+ }
+ if code := send(""); code != 403 {
+ t.Fatal("removal missing action header", code)
+ }
+ if code := send("physical-lock"); code != 409 {
+ t.Fatal("removed assigned lock", code)
+ }
+ if !m.Has(id) {
+ t.Fatal("assigned lock forgotten")
+ }
+ cfg.Locks[0].PhysicalLocks = nil
+ writeConfig(path, cfg)
+ s, e = OpenSettings(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ s.Physical = m
+ if code := send("physical-lock"); code != 204 {
+ t.Fatal("removal failed", code)
+ }
+ if _, e := os.Stat(key); !os.IsNotExist(e) {
+ t.Fatal("saved key not deleted")
+ }
+ if code := send("physical-lock"); code != 404 {
+ t.Fatal("unknown physical lock removal", code)
+ }
+}
diff --git a/internal/app/settings.go b/internal/app/settings.go
index a055799..179bbaf 100644
--- a/internal/app/settings.go
+++ b/internal/app/settings.go
@@ -87,6 +87,28 @@ func (s *Settings) Save(c Config, expected string) error {
s.restarting = true
return nil
}
+
+// Serialize removal with configuration saves so an actuator cannot disappear
+// between assignment validation and persistence.
+func (s *Settings) RemovePhysical(id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.restarting {
+ return ErrReloading
+ }
+ if s.Physical == nil || !s.Physical.Has(id) {
+ return physical.ErrNotFound
+ }
+ for _, l := range s.config.Locks {
+ for _, assigned := range l.PhysicalLocks {
+ if assigned == id {
+ return fmt.Errorf("physical lock is assigned to %s; unassign it and save configuration before removing it", l.Name)
+ }
+ }
+ }
+ return s.Physical.Remove(id)
+}
+
func writeConfig(path string, c Config) error {
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
diff --git a/internal/physical/manager.go b/internal/physical/manager.go
index d4f2f06..55e7696 100644
--- a/internal/physical/manager.go
+++ b/internal/physical/manager.go
@@ -41,15 +41,17 @@ type Enrollment struct {
} `json:"key,omitempty"`
}
type Manager struct {
- ctx context.Context
- cancel context.CancelFunc
- dir string
- dial sesame.Dialer
- enroll sync.Mutex
- mu sync.RWMutex
- records map[string]Record
- clients map[string]*sesame.Client
- wg sync.WaitGroup
+ ctx context.Context
+ cancel context.CancelFunc
+ dir string
+ dial sesame.Dialer
+ enroll sync.Mutex
+ mu sync.RWMutex
+ records map[string]Record
+ clients map[string]*sesame.Client
+ clientCancel map[string]context.CancelFunc
+ clientDone map[string]<-chan struct{}
+ wg sync.WaitGroup
}
func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error) {
@@ -57,7 +59,7 @@ func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error)
dial = sesame.DialBlueZ
}
life, cancel := context.WithCancel(ctx)
- m := &Manager{ctx: life, cancel: cancel, dir: dir, dial: dial, records: map[string]Record{}, clients: map[string]*sesame.Client{}}
+ m := &Manager{ctx: life, cancel: cancel, dir: dir, dial: dial, records: map[string]Record{}, clients: map[string]*sesame.Client{}, clientCancel: map[string]context.CancelFunc{}, clientDone: map[string]<-chan struct{}{}}
if e := os.MkdirAll(filepath.Join(dir, "physical-lock-keys"), 0700); e != nil {
cancel()
return nil, e
@@ -82,6 +84,14 @@ func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error)
cancel()
return nil, errors.New("invalid physical lock registry")
}
+ // A crash before the registry update leaves a staged key removal.
+ // Restore it when the persisted registry still references this lock.
+ if _, err := os.Stat(m.keyPath(id)); errors.Is(err, os.ErrNotExist) {
+ if err = os.Rename(m.keyPath(id)+".removing", m.keyPath(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ cancel()
+ return nil, err
+ }
+ }
c, e := m.readKey(id)
if e != nil {
cancel()
@@ -90,6 +100,31 @@ func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error)
keys[id] = c
m.records[id] = r
}
+ // After a committed removal, a crash may leave only the tombstone.
+ files, err := os.ReadDir(filepath.Join(dir, "physical-lock-keys"))
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ for _, f := range files {
+ if f.IsDir() || !strings.HasSuffix(f.Name(), ".json.removing") {
+ continue
+ }
+ id := strings.TrimSuffix(f.Name(), ".json.removing")
+ if _, ok := m.records[id]; ok {
+ continue
+ }
+ if !strings.HasPrefix(id, "sesame-") {
+ continue
+ }
+ if _, err := sesame.NormalizeUUID(strings.TrimPrefix(id, "sesame-")); err != nil {
+ continue
+ }
+ if err := os.Remove(filepath.Join(dir, "physical-lock-keys", f.Name())); err != nil {
+ cancel()
+ return nil, err
+ }
+ }
for _, r := range records {
m.start(r, keys[r.ID])
}
@@ -152,9 +187,66 @@ func writeJSON(path string, v any) error {
}
func (m *Manager) start(r Record, key sesame.Credential) {
c := sesame.NewClient(r.Target, key, m.dial)
+ ctx, cancel := context.WithCancel(m.ctx)
+ done := make(chan struct{})
m.clients[r.ID] = c
+ m.clientCancel[r.ID] = cancel
+ m.clientDone[r.ID] = done
m.wg.Add(1)
- go func() { defer m.wg.Done(); c.Run(m.ctx) }()
+ go func() { defer m.wg.Done(); defer close(done); c.Run(ctx) }()
+}
+
+var ErrNotFound = errors.New("unknown physical lock")
+
+// Remove forgets local enrollment; it never sends a reset or changes the lock.
+// App configuration must reject removal while a virtual lock references this ID.
+func (m *Manager) Remove(id string) error {
+ m.enroll.Lock()
+ defer m.enroll.Unlock()
+ m.mu.Lock()
+ if _, ok := m.records[id]; !ok {
+ m.mu.Unlock()
+ return ErrNotFound
+ }
+ // Move the credential out of the import/recovery path first. If the registry
+ // update fails, restore it and keep the active controller intact.
+ key := m.keyPath(id)
+ tombstone := key + ".removing"
+ moved := false
+ if e := os.Rename(key, tombstone); e != nil && !errors.Is(e, os.ErrNotExist) {
+ m.mu.Unlock()
+ return fmt.Errorf("remove physical lock key: %w", e)
+ } else if e == nil {
+ moved = true
+ }
+ records := make([]Record, 0, len(m.records)-1)
+ for rid, r := range m.records {
+ if rid != id {
+ records = append(records, r)
+ }
+ }
+ if e := writeJSON(filepath.Join(m.dir, "physical-locks.json"), records); e != nil {
+ var restore error
+ if moved {
+ restore = os.Rename(tombstone, key)
+ }
+ m.mu.Unlock()
+ return errors.Join(e, restore)
+ }
+ cancel, done := m.clientCancel[id], m.clientDone[id]
+ delete(m.records, id)
+ delete(m.clients, id)
+ delete(m.clientCancel, id)
+ delete(m.clientDone, id)
+ cancel()
+ m.mu.Unlock()
+ <-done // Includes transport cleanup; do not leave a reconnect goroutine alive.
+ if moved {
+ if e := os.Remove(tombstone); e != nil {
+ return fmt.Errorf("lock removed, but saved key cleanup failed: %w", e)
+ }
+ }
+ return nil
}
func (m *Manager) Close() { m.cancel(); m.enroll.Lock(); defer m.enroll.Unlock(); m.wg.Wait() }
func (m *Manager) Has(id string) bool {
diff --git a/internal/physical/manager_test.go b/internal/physical/manager_test.go
index 8a098ac..c30fc35 100644
--- a/internal/physical/manager_test.go
+++ b/internal/physical/manager_test.go
@@ -3,6 +3,7 @@ package physical
import (
"context"
"encoding/json"
+ "errors"
"os"
"path/filepath"
"strings"
@@ -152,3 +153,98 @@ func TestImportPersistsAndRestoresWithoutPublicSecret(t *testing.T) {
t.Fatal("import not restored")
}
}
+
+func removalFixture(t *testing.T) (*Manager, string, string) {
+ t.Helper()
+ dir := t.TempDir()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Registered: true}
+ id, _ := identity(target)
+ os.Mkdir(filepath.Join(dir, "physical-lock-keys"), 0700)
+ writeJSON(filepath.Join(dir, "physical-lock-keys", id+".json"), sesame.Credential{Secret: "000102030405060708090a0b0c0d0e0f"})
+ writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{{id, "Door", "sesame", target}})
+ m, e := Open(ctx, dir, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return m, id, dir
+}
+func TestRemovalDeletesKeyStopsWorkerAndPersists(t *testing.T) {
+ m, id, dir := removalFixture(t)
+ done := m.clientDone[id]
+ if e := m.Remove(id); e != nil {
+ t.Fatal(e)
+ }
+ select {
+ case <-done:
+ default:
+ t.Fatal("worker still running")
+ }
+ if m.Has(id) || len(m.Views()) != 0 {
+ t.Fatal("removed lock remains visible")
+ }
+ for _, p := range []string{m.keyPath(id), m.keyPath(id) + ".removing"} {
+ if _, e := os.Stat(p); !os.IsNotExist(e) {
+ t.Fatal("credential remains", p)
+ }
+ }
+ if e := m.Remove(id); !errors.Is(e, ErrNotFound) {
+ t.Fatal("unknown removal accepted", e)
+ }
+ m.Close()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ m, e := Open(ctx, dir, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer m.Close()
+ if m.Has(id) {
+ t.Fatal("lock returned on restart")
+ }
+}
+func TestFailedRemovalRestoresKey(t *testing.T) {
+ m, id, dir := removalFixture(t)
+ defer m.Close()
+ registry := filepath.Join(dir, "physical-locks.json")
+ os.Remove(registry)
+ os.Mkdir(registry, 0700)
+ if e := m.Remove(id); e == nil {
+ t.Fatal("accepted failed registry update")
+ }
+ if !m.Has(id) {
+ t.Fatal("lost active record")
+ }
+ if _, e := m.readKey(id); e != nil {
+ t.Fatal("lost key after failed removal", e)
+ }
+}
+func TestInterruptedRemovalRecoversOnOpen(t *testing.T) {
+ m, id, dir := removalFixture(t)
+ m.Close()
+ key := m.keyPath(id)
+ if e := os.Rename(key, key+".removing"); e != nil {
+ t.Fatal(e)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ m, e := Open(ctx, dir, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if !m.Has(id) {
+ t.Fatal("precommit removal was not rolled back")
+ }
+ m.Close()
+ os.Rename(key, key+".removing")
+ writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{})
+ m, e = Open(ctx, dir, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer m.Close()
+ if _, e = os.Stat(key + ".removing"); !os.IsNotExist(e) {
+ t.Fatal("committed removal retained key")
+ }
+}