commit bb5fe4e6e9fdb3d1dc5a72dafcee0468d7b89efd
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-13T12:52:57Z |
| subject | Added manual disconnect for sesame locks |
commit bb5fe4e6e9fdb3d1dc5a72dafcee0468d7b89efd
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-13T12:52:57Z
Added manual disconnect for sesame locks
---
build.env | 2 +-
drivers/sesame/connections.go | 88 +++++++++++++++++++++++++++++++++++++++++++
ha-app/CHANGELOG.md | 4 ++
ha-app/config.yaml | 2 +-
internal/app/page.html | 17 +++++++++
internal/app/physical.go | 24 ++++++++++++
6 files changed, 135 insertions(+), 2 deletions(-)
diff --git a/build.env b/build.env
index 8fadaae..c0c03b9 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.12
+IMAGE_TAG=0.0.13
APT_PROXY=
diff --git a/drivers/sesame/connections.go b/drivers/sesame/connections.go
new file mode 100644
index 0000000..1686818
--- /dev/null
+++ b/drivers/sesame/connections.go
@@ -0,0 +1,88 @@
+package sesame
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "strings"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// Connection is a BlueZ link, independent of local lock enrollment.
+type Connection struct {
+ Adapter string `json:"adapter"`
+ Address string `json:"address"`
+ Name string `json:"name"`
+}
+
+func sesameDevice(p map[string]dbus.Variant) bool {
+ if _, ok := advertised(p); ok {
+ return true
+ }
+ uuids, _ := p["UUIDs"].Value().([]string)
+ for _, uuid := range uuids {
+ if strings.EqualFold(uuid, ServiceUUID) {
+ return true
+ }
+ }
+ return false
+}
+
+func connectedSesames(o objects) []Connection {
+ out := []Connection{}
+ for _, ifs := range o {
+ p := ifs["org.bluez.Device1"]
+ connected, _ := p["Connected"].Value().(bool)
+ if !connected || !sesameDevice(p) {
+ continue
+ }
+ adapter, _ := p["Adapter"].Value().(dbus.ObjectPath)
+ parts := strings.Split(string(adapter), "/")
+ out = append(out, Connection{parts[len(parts)-1], value(p, "Address"), value(p, "Alias")})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].Adapter+out[i].Address < out[j].Adapter+out[j].Address
+ })
+ return out
+}
+
+// Connections reads current controller state without starting discovery.
+func Connections(ctx context.Context) ([]Connection, error) {
+ c, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, err
+ }
+ defer c.Close()
+ o, err := getObjects(ctx, c)
+ if err != nil {
+ return nil, err
+ }
+ return connectedSesames(o), nil
+}
+
+// Disconnect affects only the explicitly selected Sesame device, never its keys.
+func Disconnect(ctx context.Context, adapter, address string) error {
+ c, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return err
+ }
+ defer c.Close()
+ o, err := getObjects(ctx, c)
+ if err != nil {
+ return err
+ }
+ for path, ifs := range o {
+ p := ifs["org.bluez.Device1"]
+ a, _ := p["Adapter"].Value().(dbus.ObjectPath)
+ if string(a) != "/org/bluez/"+adapter || !strings.EqualFold(value(p, "Address"), address) || !sesameDevice(p) {
+ continue
+ }
+ connected, _ := p["Connected"].Value().(bool)
+ if !connected {
+ return nil
+ }
+ return c.Object("org.bluez", path).CallWithContext(ctx, "org.bluez.Device1.Disconnect", 0).Err
+ }
+ return errors.New("Sesame device no longer found on this adapter; refresh connections")
+}
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index c10e920..f46e50d 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.0.13
+
+- Added manual disconnect for sesame locks
+
## 0.0.12
- Move to does not work:(
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index 53a368d..cfb4ab0 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
name: Home Key Go
-version: "0.0.12"
+version: "0.0.13"
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 0132aed..3ad77f0 100644
--- a/internal/app/page.html
+++ b/internal/app/page.html
@@ -9,6 +9,7 @@
<p id="message" class="notice" role="status"></p><main id="locks"></main>
<section><h2>NFC readers</h2><p class="muted">Readers can be assigned to several locks in the same Apple Home.</p><div id="readers"></div><div class="toolbar"><button id="add-reader">Add reader</button><button id="scan">Scan Bluetooth</button></div><p id="discovery-status" class="muted" role="status"></p></section>
<section><h2>Physical locks</h2><p class="muted">Add a Sesame lock, set its positions, then assign it to a Home Key lock.</p><div id="physical-locks"></div><div class="toolbar"><button id="add-physical">Add physical lock</button><button id="scan-physical">Scan for locks</button></div><p id="physical-discovery-status" class="muted" role="status"></p></section>
+<section><h2>Bluetooth connections</h2><p class="muted">Connected Sesame devices, including locks not yet added above. Disconnect a stuck connection before retrying registration. Configured locks may reconnect automatically.</p><button id="refresh-connections" type="button">Refresh connections</button><p id="connections-status" class="muted" role="status"></p><div id="sesame-connections"></div></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><fieldset><legend>Assigned physical locks</legend><div id="physical-assignments"></div><small>With none assigned, this remains a simulated lock.</small></fieldset><details><summary>HomeKit settings</summary><label>HomeKit port <input id="lock-port" type="number" min="1" max="65535" required></label><label>Wallet artwork <select id="lock-finish"><option>silver</option><option>black</option><option>gold</option><option>tan</option></select></label></details><p id="lock-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-lock">Cancel</button></div></form></dialog>
@@ -68,6 +69,22 @@ function selectedPhysical(){return physicalDiscovery.locks.find(t=>t.adapter+'|'
function selectPhysical(){const t=selectedPhysical();if(t){$('physical-mode').value=t.registered?'import':'register';if(!$('physical-name').value)$('physical-name').value=t.model.replaceAll('_',' ')}physicalMode();$('physical-submit').disabled=!t||physicalAdding}
function physicalMode(){const registering=$('physical-mode').value==='register';$('physical-key-label').hidden=registering;$('physical-key').required=!registering;$('physical-register-note').hidden=!registering}
async function scanPhysical(){if(physicalScanning)return;physicalScanning=true;$('scan-physical').disabled=true;$('physical-discovery-status').textContent='Scanning for supported Sesame locks…';try{physicalDiscovery=await request('api/physical-locks/discovery');$('physical-discovery-status').textContent=physicalDiscovery.locks.length+' supported lock(s) found. '+(physicalDiscovery.warnings||[]).join(' · ');if($('physical-dialog').open)physicalChoices()}catch(e){$('physical-discovery-status').textContent=e.message;if($('physical-dialog').open)$('physical-error').textContent=e.message}finally{physicalScanning=false;$('scan-physical').disabled=false}}
+let connectionsBusy=false;
+async function refreshConnections(){
+ if(connectionsBusy)return;
+ connectionsBusy=true;$('refresh-connections').disabled=true;$('connections-status').textContent='Reading Bluetooth connections…';
+ try{const connections=await request('api/bluetooth/sesame-connections');const list=$('sesame-connections');list.replaceChildren();
+ for(const c of connections){const row=el('div','','reader'),info=el('div','');info.append(el('strong',c.name||c.address),el('p',c.address+' · '+c.adapter+' · Connected','muted'));const disconnect=button('Disconnect',async()=>{
+ disconnect.disabled=true;$('connections-status').textContent='Disconnecting '+c.address+'…';
+ try{await request('api/bluetooth/sesame-connections/'+encodeURIComponent(c.adapter)+'/'+encodeURIComponent(c.address)+'/disconnect',{method:'POST',headers:{'X-Homekey-Action':'physical-lock'}});await refreshConnections()}
+ catch(e){$('connections-status').textContent=e.message;disconnect.disabled=false}
+ });row.append(info,disconnect);list.append(row)}
+ $('connections-status').textContent=connections.length?connections.length+' connected Sesame device(s).':'No connected Sesame devices.';
+ }catch(e){$('sesame-connections').replaceChildren();$('connections-status').textContent=e.message}
+ finally{connectionsBusy=false;$('refresh-connections').disabled=false}
+}
+$('refresh-connections').onclick=refreshConnections;
+refreshConnections();
$('scan-physical').onclick=scanPhysical;
$('add-physical').onclick=()=>{$('physical-name').value='';$('physical-key').value='';$('physical-error').textContent='';physicalChoices();$('physical-dialog').showModal();scanPhysical()};
$('physical-device').onchange=selectPhysical;$('physical-mode').onchange=physicalMode;
diff --git a/internal/app/physical.go b/internal/app/physical.go
index f2b87fa..461e649 100644
--- a/internal/app/physical.go
+++ b/internal/app/physical.go
@@ -81,6 +81,30 @@ func physicalRoutes(mux *http.ServeMux, cfg *Settings) {
if cfg.Physical == nil {
return
}
+ mux.HandleFunc("GET /api/bluetooth/sesame-connections", func(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+ connections, err := sesame.Connections(ctx)
+ if err != nil {
+ http.Error(w, err.Error(), 503)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(connections)
+ })
+ mux.HandleFunc("POST /api/bluetooth/sesame-connections/{adapter}/{address}/disconnect", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Homekey-Action") != "physical-lock" {
+ http.Error(w, "missing action header", 403)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ if err := sesame.Disconnect(ctx, r.PathValue("adapter"), r.PathValue("address")); err != nil {
+ http.Error(w, err.Error(), 409)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ })
mux.HandleFunc("GET /api/physical-locks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg.Physical.Views())