From fe532aebef654e4f8a13cff90fe8058d9eb1436f Mon Sep 17 00:00:00 2001 From: Viggo Fredriksen Date: Tue, 7 Jul 2026 00:19:10 +0200 Subject: [PATCH 1/2] blissha: resolve adapter by MAC via BlueZ D-Bus, not sysfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning a blind to an adapter by its Bluetooth MAC failed on Ubuntu 26.04 with "no bluetooth adapter found", even for the correct, present controller. The resolver read the address from /sys/class/bluetooth/hciN/address, but that kernel/BlueZ no longer exposes an `address` file under the adapter's sysfs dir, so every adapter was skipped. (It would also fail in a container that mounts the D-Bus socket but not host sysfs.) Resolve the MAC through BlueZ over D-Bus instead (ObjectManager → org.bluez.Adapter1.Address), which is authoritative and matches how the bridge already drives the adapters. The sysfs read is kept as a fallback for older systems where D-Bus enumeration isn't available. Matching remains case-insensitive (it always was — EqualFold), so this was never a case issue. Adds unit tests for the sysfs matcher (fixture dir) and the object-path→hciN helper; the live D-Bus path is exercised on-device. --- go.mod | 2 +- pkg/blissha/adapter.go | 69 +++++++++++++++++++++++++++++++++---- pkg/blissha/adapter_test.go | 48 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) 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/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: //address holding +// the MAC plus a trailing newline (as the real /sys/class/bluetooth files do). +func writeAdapter(t *testing.T, dir, hci, addr string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, hci), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, hci, "address"), []byte(addr+"\n"), 0o644)) +} + +func TestHCIForAddressCaseInsensitive(t *testing.T) { + dir := t.TempDir() + // The user's setup: one controller, 84:5C:F3:EC:8B:4F, as hci0. sysfs commonly + // stores the address lower-cased, so use lower case in the fixture. + writeAdapter(t, dir, "hci0", "84:5c:f3:ec:8b:4f") + writeAdapter(t, dir, "hci1", "00:1a:7d:11:22:33") + + // An uppercase config value (what the user tried) resolves fine. + got, err := hciForAddressIn(dir, "84:5C:F3:EC:8B:4F") + require.NoError(t, err) + require.Equal(t, "hci0", got) + + // Lowercase and whitespace-padded also work. + got, err = hciForAddressIn(dir, " 84:5c:f3:ec:8b:4f ") + require.NoError(t, err) + require.Equal(t, "hci0", got) + + got, err = hciForAddressIn(dir, "00:1A:7D:11:22:33") + require.NoError(t, err) + require.Equal(t, "hci1", got) + + // An address no adapter has → error. + _, err = hciForAddressIn(dir, "AA:BB:CC:DD:EE:FF") + require.Error(t, err) + + // A missing/empty sysfs dir (e.g. inside a container without host sysfs) + // errors clearly rather than matching — this is the likely real-world cause. + _, err = hciForAddressIn(filepath.Join(dir, "does-not-exist"), "84:5C:F3:EC:8B:4F") + require.Error(t, err) +} + +func TestAdapterIDFromPath(t *testing.T) { + require.Equal(t, "hci0", adapterIDFromPath(dbus.ObjectPath("/org/bluez/hci0"))) + require.Equal(t, "hci1", adapterIDFromPath(dbus.ObjectPath("/org/bluez/hci1"))) + require.Equal(t, "hci0", adapterIDFromPath(dbus.ObjectPath("hci0"))) +} From cc2f3c6bb866b6a146c1e0a4cb58238fb378fdb3 Mon Sep 17 00:00:00 2001 From: Viggo Fredriksen Date: Tue, 7 Jul 2026 00:32:54 +0200 Subject: [PATCH 2/2] blissha: serialize BLE scans across adapters to avoid cross-talk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With one dongle per room, the two blinds scanned concurrently on different adapters and the second blind never connected: it failed with "scan was stopped unexpectedly" and then "Operation already in progress" on every retry. Root cause is in tinygo/x/bluetooth (v0.15.0) Linux scanning: Scan watches the org.bluez.Adapter1 "Discovering" property but does not filter by adapter path, so when one adapter's scan finds its device and stops discovery, a scan running concurrently on a *different* adapter sees Discovering=false, aborts, and leaks its StartDiscovery — leaving that adapter stuck "in progress". Use a single shared scan mutex for all blinds (instead of one per adapter) so only one BLE scan runs at a time across every adapter. Connects still run in parallel and scans are brief, so the practical cost is negligible. Docs updated; the ScanMutex contract now documents the cross-adapter hazard. --- README.md | 10 ++++++---- pkg/bliss/client.go | 8 +++++--- pkg/blissha/bridge.go | 17 +++++++++++------ 3 files changed, 22 insertions(+), 13 deletions(-) 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/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/bridge.go b/pkg/blissha/bridge.go index 8f23ece..679b5ed 100644 --- a/pkg/blissha/bridge.go +++ b/pkg/blissha/bridge.go @@ -59,11 +59,17 @@ func New(cfg Config, logger *slog.Logger) (*Bridge, error) { } b.client = mqtt.NewClient(opts) - // Cache one Adapter and one scan mutex per physical dongle (keyed by hci id), - // so blinds on the same adapter serialize scans while different adapters scan - // in parallel. + // Cache one Adapter per physical dongle (keyed by hci id). All blinds share a + // SINGLE scan mutex so only one BLE scan runs at a time across every adapter. + // This is required, not just an optimization: tinygo/x/bluetooth's Linux Scan + // watches the org.bluez.Adapter1 "Discovering" property without filtering by + // adapter path, so when a scan on one adapter finishes and stops discovery, a + // scan running concurrently on a *different* adapter aborts with "scan was + // stopped unexpectedly" and leaks its discovery ("Operation already in + // progress"). Serializing scans avoids the overlap entirely. Connects still + // run in parallel; scans are brief. adapters := map[string]*bluetooth.Adapter{} - scanMus := map[string]*sync.Mutex{} + scanMu := &sync.Mutex{} for _, bc := range cfg.Blinds { id, err := resolveHCI(bc.Adapter) if err != nil { @@ -74,9 +80,8 @@ func New(cfg Config, logger *slog.Logger) (*Bridge, error) { if !ok { adapter = bluetooth.NewAdapter(id) adapters[id] = adapter - scanMus[id] = &sync.Mutex{} } - mgr := newManager(cfg.MQTT, bc, cfg.Location, b.client, adapter, scanMus[id], cfg.Poll, cfg.IdleDisconnect, logger) + mgr := newManager(cfg.MQTT, bc, cfg.Location, b.client, adapter, scanMu, cfg.Poll, cfg.IdleDisconnect, logger) b.managers = append(b.managers, mgr) b.byID[mgr.id] = mgr }