internal/blegateway/gateway.go
raw ยท 5975 bytes
// 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
}