commit a749fbc16d1dbf7a4fd0a2dab13e33b5ce6421ed
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-04T10:41:51Z |
| subject | Added 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)
+ }
+}