diff --git a/README.md b/README.md index aeb35fa..d608e83 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,12 @@ blinds: **Multiple adapters (multi-room).** BLE range is short, so for blinds in different rooms you can use one USB Bluetooth dongle per room (e.g. on a USB -extension). Set each blind's `adapter:` to that dongle's own Bluetooth MAC — -stable across reboots, unlike `hciN` numbering. Blinds on the same adapter -serialize their scans; different adapters scan in parallel. Omit `adapter:` to -use the default (`hci0`). BlueZ handles the multiple adapters automatically. +extension). Set each blind's `adapter:` to that dongle's own Bluetooth MAC (the +adapter is resolved via BlueZ over D-Bus, so the MAC is stable across reboots, +unlike `hciN` numbering — or give `hci0`/`hci1` directly). Omit `adapter:` to +use the default (`hci0`). BlueZ drives the dongles in parallel, but scans are +serialized across all of them (one at a time) to avoid a cross-adapter +discovery conflict in the BLE stack. ### Power saving diff --git a/go.mod b/go.mod index 9b024ea..2f7a5ad 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.1 require ( github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/godbus/dbus/v5 v5.1.0 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 tinygo.org/x/bluetooth v0.15.0 @@ -12,7 +13,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/saltosystems/winrt-go v0.0.0-20260317170058-9c2fec580d96 // indirect diff --git a/pkg/bliss/client.go b/pkg/bliss/client.go index 03d6fc9..8a30c26 100644 --- a/pkg/bliss/client.go +++ b/pkg/bliss/client.go @@ -25,9 +25,11 @@ type Config struct { Logger *slog.Logger // ScanTimeout bounds discovery per Connect. Defaults to 20s. ScanTimeout time.Duration - // ScanMutex, if set, is held for the duration of each BLE scan. The adapter - // permits only one scan at a time, so share one mutex across all Blind - // instances that use the same adapter to serialize their scans/reconnects. + // ScanMutex, if set, is held for the duration of each BLE scan. Share one + // mutex across all Blind instances so only one scan runs at a time — BlueZ + // permits one scan per adapter, and the tinygo/x/bluetooth Linux scanner + // additionally cross-talks between adapters (a scan stopping on one adapter + // aborts a concurrent scan on another), so a single shared mutex is safest. ScanMutex *sync.Mutex } diff --git a/pkg/blissha/adapter.go b/pkg/blissha/adapter.go index 402db83..80763ea 100644 --- a/pkg/blissha/adapter.go +++ b/pkg/blissha/adapter.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/godbus/dbus/v5" ) const sysBluetoothDir = "/sys/class/bluetooth" @@ -29,19 +31,74 @@ func resolveHCI(spec string) (string, error) { } } -// hciForAddress finds the adapter whose Bluetooth address equals mac by reading -// /sys/class/bluetooth/hci*/address. +// hciForAddress finds the adapter whose Bluetooth address equals mac. It asks +// BlueZ over D-Bus (the authoritative source, and the same channel the bridge +// uses to drive the adapters) and falls back to reading the sysfs address only +// if D-Bus can't be queried. The D-Bus path matters because some kernels no +// longer expose /sys/class/bluetooth/hciN/address (e.g. Ubuntu 26.04), and it +// also works inside a container that mounts the D-Bus socket but not host sysfs. func hciForAddress(mac string) (string, error) { - entries, err := os.ReadDir(sysBluetoothDir) + id, err := hciByAddressDBus(mac) + if err == nil { + return id, nil + } + if id, ferr := hciForAddressIn(sysBluetoothDir, mac); ferr == nil { + return id, nil + } + return "", err // surface the primary (D-Bus) error +} + +// hciByAddressDBus enumerates BlueZ adapters via the ObjectManager and returns +// the one whose org.bluez.Adapter1.Address matches mac (case-insensitively). +func hciByAddressDBus(mac string) (string, error) { + conn, err := dbus.SystemBus() // shared connection; do not close if err != nil { - return "", fmt.Errorf("list bluetooth adapters: %w", err) + return "", fmt.Errorf("connect system bus: %w", err) + } + var managed map[dbus.ObjectPath]map[string]map[string]dbus.Variant + if err := conn.Object("org.bluez", "/"). + Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&managed); err != nil { + return "", fmt.Errorf("query bluez adapters over d-bus: %w", err) + } + want := strings.TrimSpace(mac) + for path, ifaces := range managed { + props, ok := ifaces["org.bluez.Adapter1"] + if !ok { + continue + } + addr, _ := props["Address"].Value().(string) + if strings.EqualFold(strings.TrimSpace(addr), want) { + return adapterIDFromPath(path), nil + } + } + return "", fmt.Errorf("no bluetooth adapter found with address %s", mac) +} + +// adapterIDFromPath turns a BlueZ object path (/org/bluez/hci0) into its adapter +// id (hci0). +func adapterIDFromPath(p dbus.ObjectPath) string { + s := string(p) + if i := strings.LastIndex(s, "/"); i >= 0 { + return s[i+1:] + } + return s +} + +// hciForAddressIn matches mac against the sysfs address files under baseDir. This +// is the legacy fallback; matching is case-insensitive and whitespace-tolerant +// (sysfs address files end in a newline, and BlueZ may report upper or lower +// case). Injecting baseDir keeps the logic unit-testable. +func hciForAddressIn(baseDir, mac string) (string, error) { + entries, err := os.ReadDir(baseDir) + if err != nil { + return "", fmt.Errorf("list bluetooth adapters in %s: %w", baseDir, err) } for _, e := range entries { name := e.Name() if !strings.HasPrefix(name, "hci") { continue } - data, err := os.ReadFile(filepath.Join(sysBluetoothDir, name, "address")) + data, err := os.ReadFile(filepath.Join(baseDir, name, "address")) if err != nil { continue } @@ -49,5 +106,5 @@ func hciForAddress(mac string) (string, error) { return name, nil } } - return "", fmt.Errorf("no bluetooth adapter found with address %s", mac) + return "", fmt.Errorf("no bluetooth adapter found with address %s (looked in %s)", mac, baseDir) } diff --git a/pkg/blissha/adapter_test.go b/pkg/blissha/adapter_test.go index 4d3c344..36d5dcb 100644 --- a/pkg/blissha/adapter_test.go +++ b/pkg/blissha/adapter_test.go @@ -1,8 +1,11 @@ package blissha import ( + "os" + "path/filepath" "testing" + "github.com/godbus/dbus/v5" "github.com/stretchr/testify/require" ) @@ -23,3 +26,48 @@ func TestResolveHCI(t *testing.T) { _, err = resolveHCI("00:00:00:00:00:99") require.Error(t, err, "unknown adapter MAC should error") } + +// writeAdapter creates a fake sysfs adapter entry: