penguin/homekey-go

homekey-go

commit a749fbc16d1dbf7a4fd0a2dab13e33b5ce6421ed

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-04T10:41:51Z
subjectAdded PING test
commit a749fbc16d1dbf7a4fd0a2dab13e33b5ce6421ed
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-04T10:41:51Z

    Added PING test
---
 Dockerfile                          |   2 +-
 cmd/homekey/main.go                 |  28 +++++-
 go.mod                              |   5 +-
 go.sum                              |   2 +
 ha-app/CHANGELOG.md                 |   5 +
 ha-app/DOCS.md                      |  70 ++++++++++++++
 ha-app/config.yaml                  |   7 +-
 ha-app/run.sh                       |   5 +
 ha-app/translations/en.yaml         |   6 ++
 internal/blegateway/gateway.go      | 185 ++++++++++++++++++++++++++++++++++++
 internal/blegateway/gateway_test.go |  24 +++++
 11 files changed, 335 insertions(+), 4 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index ed2d680..1de8228 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,7 +10,7 @@ RUN CGO_ENABLED=0 go build -mod=readonly -buildvcs=false -trimpath \
     -ldflags='-s -w' -o /out/homekey ./cmd/homekey
 
 FROM debian:bookworm-slim
-ARG BUILD_VERSION=0.0.1
+ARG BUILD_VERSION=0.0.2
 ARG BUILD_ARCH=amd64
 LABEL io.hass.type="app" \
       io.hass.version="${BUILD_VERSION}" \
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
index 6e320fd..9c74530 100644
--- a/cmd/homekey/main.go
+++ b/cmd/homekey/main.go
@@ -8,6 +8,7 @@ import (
 	"errors"
 	"flag"
 	"fmt"
+	"homekey.local/provisioner/internal/blegateway"
 	"log"
 	"math/big"
 	"net"
@@ -17,6 +18,7 @@ import (
 	"path/filepath"
 	"strings"
 	"syscall"
+	"time"
 
 	"github.com/brutella/hap"
 	"homekey.local/provisioner/internal/hkserver"
@@ -38,7 +40,21 @@ func run() error {
 	iface := flag.String("interface", "", "LAN interface for mDNS; empty selects eligible interfaces")
 	finish := flag.String("finish", "silver", "Wallet artwork: silver, black, gold, tan")
 	status := flag.Bool("status", false, "print credential counts without starting the server")
+	bleReader := flag.String("ble-reader", "", "ESP32 Bluetooth MAC; empty disables BLE probe")
+	bleAdapter := flag.String("ble-adapter", "hci0", "BlueZ adapter name")
+	bleInterval := flag.Duration("ble-ping-interval", 10*time.Second, "BLE PING interval")
 	flag.Parse()
+	if *bleReader != "" {
+		if err := blegateway.ValidateAddress(*bleReader); err != nil {
+			return err
+		}
+		if !strings.HasPrefix(*bleAdapter, "hci") || strings.ContainsAny(*bleAdapter, "/. ") {
+			return fmt.Errorf("invalid BLE adapter")
+		}
+		if *bleInterval < time.Second {
+			return fmt.Errorf("BLE PING interval must be at least 1s")
+		}
+	}
 	if flag.NArg() != 0 {
 		return fmt.Errorf("unexpected positional argument")
 	}
@@ -118,8 +134,18 @@ func run() error {
 	log.Printf("HAP address %s; persistent state %s", *addr, *state)
 	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
 	defer cancel()
+	var bleDone chan struct{}
+	if *bleReader != "" {
+		bleDone = make(chan struct{})
+		go func() { defer close(bleDone); blegateway.Run(ctx, *bleReader, *bleAdapter, *bleInterval) }()
+	}
 	err = dev.Server.ListenAndServe(ctx)
-	if ctx.Err() != nil || errors.Is(err, http.ErrServerClosed) {
+	stopped := ctx.Err() != nil
+	cancel()
+	if bleDone != nil {
+		<-bleDone
+	}
+	if stopped || errors.Is(err, context.Canceled) || errors.Is(err, http.ErrServerClosed) || err == nil {
 		return nil
 	}
 	return err
diff --git a/go.mod b/go.mod
index 9dbd1d0..dbfe4e5 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,10 @@ module homekey.local/provisioner
 
 go 1.23.0
 
-require github.com/brutella/hap v0.0.35
+require (
+	github.com/brutella/hap v0.0.35
+	github.com/godbus/dbus/v5 v5.1.0
+)
 
 require (
 	github.com/brutella/dnssd v1.2.14 // indirect
diff --git a/go.sum b/go.sum
index 4abdff0..2667326 100644
--- a/go.sum
+++ b/go.sum
@@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
 github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
 github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index 7c5ae07..0af9d40 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.0.2
+
+- Optional direct BlueZ BLE PING/PONG probe with reconnect and matching sequence numbers.
+- Enable host D-Bus access and add BLE reader/adapter options.
+
 # Changelog
 
 ## 0.0.1
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index 4bc4e1c..8aeb83d 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -122,3 +122,73 @@ This packages the existing provisioning service unchanged. The HAP listener
 supports Apple Home pairing and Home Key provisioning; lock toggles are still
 simulated. No reader API, MQTT connection, Home Assistant entity integration,
 or ESP32/PN532 authentication transport is added by these files.
+
+
+# Initial BLE PING/PONG transport
+
+Copy the files under `homekey-go/` and `homekey-relay/` into their corresponding
+repositories. This bundle contains changed/new files only, plus unified patches.
+Do not replace the whole repositories with these directories.
+
+## ESP32
+
+Build and flash as before:
+
+    cd homekey-relay/esp32
+    idf.py set-target esp32c3
+    idf.py build
+    idf.py -p /dev/cu.usbmodem101 flash monitor
+
+Copy `BLE address AA:BB:CC:DD:EE:FF` from the serial log.
+The reader now continuously advertises a connectable GATT service until connected.
+PN532 polling continues unchanged. The previous non-connectable UID broadcasts
+are replaced; the old HACS UID sensor will no longer receive those events.
+Card events are logged locally; forwarding them is a subsequent step.
+
+## Go / HA app
+
+Publish the rebuilt image as `ghcr.io/tgckpg/homekey-go:0.0.2` and update the app,
+or use `sh scripts/stage-ha-app.sh` for a local build as before.
+The app config enables `host_dbus: true`; no raw USB access is needed.
+Keep the existing HomeKit options and add:
+
+    ble_reader: "AA:BB:CC:DD:EE:FF"
+    ble_adapter: "hci0"
+
+Restart the app. Expected Go log:
+
+    BLE gateway: connected to AA:BB:CC:DD:EE:FF
+    BLE PONG reader=AA:BB:CC:DD:EE:FF seq=1 round_trip=...
+
+Expected ESP32 log:
+
+    BLE gateway connected
+    BLE PING received; PONG ready
+
+Empty `ble_reader` disables the probe. It sends every 10 seconds; failures retry
+in 5 seconds without taking down HAP. The BlueZ adapter must be powered and
+recognized by HA OS. If logs show D-Bus/AppArmor access denied, inspect the host
+denial before changing permissions. This backend does not use ESPHome proxies.
+
+Standalone Linux:
+
+    go run ./cmd/homekey -ble-reader AA:BB:CC:DD:EE:FF -ble-adapter hci0
+
+Optional `-ble-ping-interval 10s`, minimum 1 second.
+
+## Protocol v0
+
+Service UUID: 7a6b0001-5b21-4f36-8e5d-9c3a26d74210
+Read/write characteristic: 7a6b0002-5b21-4f36-8e5d-9c3a26d74210
+Write with response: 8 bytes = ASCII PING + uint32 sequence, big endian.
+Read: 8 bytes = ASCII PONG + matching sequence; empty before first PING.
+All messages fit the default ATT MTU. The ESP32 prepares PONG in the write callback.
+The Go client verifies the sequence and records write-plus-read elapsed time.
+No notifications, pairing, bonding, NFC authentication, or unlocking in this probe.
+Only one gateway should connect to the reader at a time.
+
+## Validation
+
+Go tests, vet, Linux build, and shell syntax checked in the build environment.
+ESP-IDF compilation and end-to-end radio testing require your ESP-IDF toolchain
+and hardware and have not been performed here.
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index f5f933d..debdfbd 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.1"
+version: "0.0.2"
 slug: homekey_go
 description: Virtual HomeKit lock and Apple Home Key provisioning service
 url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/
@@ -10,6 +10,7 @@ startup: services
 boot: auto
 init: true
 host_network: true
+host_dbus: true
 backup: cold
 stage: experimental
 image: ghcr.io/tgckpg/homekey-go
@@ -20,6 +21,8 @@ options:
   interface: ""
   finish: silver
   pin: ""
+  ble_reader: ""
+  ble_adapter: hci0
 schema:
   name: str(1,)
   serial: str(1,)
@@ -27,3 +30,5 @@ schema:
   interface: str
   finish: list(silver|black|gold|tan)
   pin: password
+  ble_reader: str?
+  ble_adapter: str?
diff --git a/ha-app/run.sh b/ha-app/run.sh
index 7d953e4..f0ee495 100755
--- a/ha-app/run.sh
+++ b/ha-app/run.sh
@@ -26,6 +26,11 @@ if [ -f /data/options.json ]; then
     port=$(jq -r '.port' /data/options.json)
     iface=$(jq -r '.interface' /data/options.json)
     finish=$(jq -r '.finish' /data/options.json)
+    ble_reader=$(jq -r '.ble_reader // ""' /data/options.json)
+    ble_adapter=$(jq -r '.ble_adapter // "hci0"' /data/options.json)
+    if [ -n "$ble_reader" ]; then
+        set -- "$@" -ble-reader "$ble_reader" -ble-adapter "$ble_adapter"
+    fi
     pin=$(jq -r '.pin' /data/options.json)
     set -- "$@" -name "$name" -serial "$serial" -addr ":$port" -finish "$finish"
     if [ -n "$iface" ]; then
diff --git a/ha-app/translations/en.yaml b/ha-app/translations/en.yaml
index 95437bc..1cbd0e7 100644
--- a/ha-app/translations/en.yaml
+++ b/ha-app/translations/en.yaml
@@ -17,3 +17,9 @@ configuration:
   pin:
     name: Pairing PIN
     description: Leave empty to generate and save a PIN. Otherwise use eight digits.
+  ble_reader:
+    name: BLE reader address
+    description: ESP32 MAC from its serial log. Leave empty to disable the PING probe.
+  ble_adapter:
+    name: Bluetooth adapter
+    description: Local BlueZ adapter, usually hci0.
diff --git a/internal/blegateway/gateway.go b/internal/blegateway/gateway.go
new file mode 100644
index 0000000..52444dc
--- /dev/null
+++ b/internal/blegateway/gateway.go
@@ -0,0 +1,185 @@
+// SPDX-License-Identifier: Apache-2.0
+// Package blegateway implements the initial Linux BlueZ PING/PONG probe.
+package blegateway
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"fmt"
+	"log"
+	"net"
+	"strings"
+	"time"
+
+	"github.com/godbus/dbus/v5"
+)
+
+const ServiceUUID = "7a6b0001-5b21-4f36-8e5d-9c3a26d74210"
+const PingUUID = "7a6b0002-5b21-4f36-8e5d-9c3a26d74210"
+
+type objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+// ValidateAddress deliberately requires an explicit reader, rather than
+// connecting to whichever device happens to advertise our service first.
+func ValidateAddress(address string) error {
+	a, err := net.ParseMAC(address)
+	if err != nil || len(a) != 6 {
+		return fmt.Errorf("BLE address must be six colon-separated octets")
+	}
+	if strings.Count(address, ":") != 5 {
+		return fmt.Errorf("BLE address must use colon separators")
+	}
+	return nil
+}
+func request(seq uint32) []byte {
+	b := make([]byte, 8)
+	copy(b, "PING")
+	binary.BigEndian.PutUint32(b[4:], seq)
+	return b
+}
+func validReply(b []byte, seq uint32) bool {
+	return len(b) == 8 && bytes.Equal(b[:4], []byte("PONG")) && binary.BigEndian.Uint32(b[4:]) == seq
+}
+func wait(ctx context.Context, d time.Duration) bool {
+	t := time.NewTimer(d)
+	defer t.Stop()
+	select {
+	case <-ctx.Done():
+		return false
+	case <-t.C:
+		return true
+	}
+}
+func getObjects(ctx context.Context, c *dbus.Conn) (objects, error) {
+	var o objects
+	err := c.Object("org.bluez", "/").CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&o)
+	return o, err
+}
+func text(p map[string]dbus.Variant, k string) string { v, _ := p[k].Value().(string); return v }
+
+// Run reconnects independently of HAP. A Bluetooth failure does not stop
+// HomeKit provisioning. Each attempt gets a fresh system-bus connection.
+func Run(ctx context.Context, address, adapter string, interval time.Duration) {
+	for ctx.Err() == nil {
+		if err := session(ctx, strings.ToUpper(address), adapter, interval); err != nil && ctx.Err() == nil {
+			log.Printf("BLE gateway: %v; retrying in 5s", err)
+		}
+		if !wait(ctx, 5*time.Second) {
+			return
+		}
+	}
+}
+func session(ctx context.Context, address, adapter string, interval time.Duration) error {
+	c, err := dbus.ConnectSystemBus()
+	if err != nil {
+		return fmt.Errorf("system D-Bus: %w", err)
+	}
+	defer c.Close()
+	adapterPath := dbus.ObjectPath("/org/bluez/" + adapter)
+	a := c.Object("org.bluez", adapterPath)
+	// BlueZ reference-counts discovery sessions; stop only our own session.
+	scanCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	err = a.CallWithContext(scanCtx, "org.bluez.Adapter1.StartDiscovery", 0).Err
+	cancel()
+	if err != nil {
+		return fmt.Errorf("start discovery on %s: %w", adapter, err)
+	}
+	stopScan := func() {
+		cleanup, done := context.WithTimeout(context.Background(), 3*time.Second)
+		defer done()
+		_ = a.CallWithContext(cleanup, "org.bluez.Adapter1.StopDiscovery", 0).Err
+	}
+	scanning := true
+	defer func() {
+		if scanning {
+			stopScan()
+		}
+	}()
+	log.Printf("BLE gateway: discovering %s on %s", address, adapter)
+	discoverCtx, done := context.WithTimeout(ctx, 30*time.Second)
+	defer done()
+	var device dbus.ObjectPath
+	for device == "" {
+		o, e := getObjects(discoverCtx, c)
+		if e != nil {
+			return e
+		}
+		for p, interfaces := range o {
+			props, ok := interfaces["org.bluez.Device1"]
+			if ok && strings.HasPrefix(string(p), string(adapterPath)+"/") && strings.EqualFold(text(props, "Address"), address) {
+				device = p
+				break
+			}
+		}
+		if device == "" && !wait(discoverCtx, 250*time.Millisecond) {
+			return fmt.Errorf("reader discovery: %w", discoverCtx.Err())
+		}
+	}
+	d := c.Object("org.bluez", device)
+	connectCtx, connectDone := context.WithTimeout(ctx, 20*time.Second)
+	err = d.CallWithContext(connectCtx, "org.bluez.Device1.Connect", 0).Err
+	connectDone()
+	if err != nil {
+		return fmt.Errorf("connect %s: %w", address, err)
+	}
+	defer func() {
+		cleanup, done := context.WithTimeout(context.Background(), 3*time.Second)
+		defer done()
+		_ = d.CallWithContext(cleanup, "org.bluez.Device1.Disconnect", 0).Err
+	}()
+	stopScan()
+	scanning = false
+	resolveCtx, resolveDone := context.WithTimeout(ctx, 20*time.Second)
+	defer resolveDone()
+	var characteristic dbus.ObjectPath
+	for characteristic == "" {
+		o, e := getObjects(resolveCtx, c)
+		if e != nil {
+			return e
+		}
+		resolved, _ := o[device]["org.bluez.Device1"]["ServicesResolved"].Value().(bool)
+		if resolved {
+			for p, interfaces := range o {
+				props, ok := interfaces["org.bluez.GattCharacteristic1"]
+				if !ok || !strings.EqualFold(text(props, "UUID"), PingUUID) {
+					continue
+				}
+				service, _ := props["Service"].Value().(dbus.ObjectPath)
+				sp := o[service]["org.bluez.GattService1"]
+				owner, _ := sp["Device"].Value().(dbus.ObjectPath)
+				if owner == device && strings.EqualFold(text(sp, "UUID"), ServiceUUID) {
+					characteristic = p
+					break
+				}
+			}
+		}
+		if characteristic == "" && !wait(resolveCtx, 250*time.Millisecond) {
+			return fmt.Errorf("PING service resolution: %w", resolveCtx.Err())
+		}
+	}
+	log.Printf("BLE gateway: connected to %s", address)
+	ch := c.Object("org.bluez", characteristic)
+	for seq := uint32(1); ctx.Err() == nil; seq++ {
+		pingCtx, pingDone := context.WithTimeout(ctx, 5*time.Second)
+		started := time.Now()
+		err := ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.WriteValue", 0,
+			request(seq), map[string]dbus.Variant{"type": dbus.MakeVariant("request")}).Err
+		var reply []byte
+		if err == nil {
+			err = ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.ReadValue", 0, map[string]dbus.Variant{}).Store(&reply)
+		}
+		pingDone()
+		if err != nil {
+			return fmt.Errorf("PING %d: %w", seq, err)
+		}
+		if !validReply(reply, seq) {
+			return fmt.Errorf("PING %d: unexpected reply %x", seq, reply)
+		}
+		log.Printf("BLE PONG reader=%s seq=%d round_trip=%s", address, seq, time.Since(started).Round(time.Millisecond))
+		if !wait(ctx, interval) {
+			return nil
+		}
+	}
+	return nil
+}
diff --git a/internal/blegateway/gateway_test.go b/internal/blegateway/gateway_test.go
new file mode 100644
index 0000000..da32337
--- /dev/null
+++ b/internal/blegateway/gateway_test.go
@@ -0,0 +1,24 @@
+package blegateway
+
+import "testing"
+
+func TestReply(t *testing.T) {
+	b := request(42)
+	copy(b, "PONG")
+	if !validReply(b, 42) {
+		t.Fatal("valid reply rejected")
+	}
+	if validReply(b, 41) || validReply(b[:7], 42) || validReply(request(42), 42) {
+		t.Fatal("stale or malformed reply accepted")
+	}
+}
+func TestAddress(t *testing.T) {
+	for _, a := range []string{"", "aa:bb:cc", "aabb.ccdd.eeff", "garbage"} {
+		if ValidateAddress(a) == nil {
+			t.Fatalf("accepted %q", a)
+		}
+	}
+	if err := ValidateAddress("AA:BB:CC:DD:EE:FF"); err != nil {
+		t.Fatal(err)
+	}
+}