Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions pkg/bliss/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
69 changes: 63 additions & 6 deletions pkg/blissha/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"strings"

"github.com/godbus/dbus/v5"
)

const sysBluetoothDir = "/sys/class/bluetooth"
Expand All @@ -29,25 +31,80 @@ 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
}
if strings.EqualFold(strings.TrimSpace(string(data)), strings.TrimSpace(mac)) {
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)
}
48 changes: 48 additions & 0 deletions pkg/blissha/adapter_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package blissha

import (
"os"
"path/filepath"
"testing"

"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/require"
)

Expand All @@ -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: <dir>/<hci>/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")))
}
17 changes: 11 additions & 6 deletions pkg/blissha/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
Loading