From 070090c35a2275cee53a232214842e67bfafd2b6 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sun, 16 Aug 2026 11:36:32 -0400 Subject: [PATCH] bluetooth: multi-adapter support --- core/internal/server/bluez/handlers.go | 6 +- core/internal/server/bluez/manager.go | 214 ++++++++++++++---- core/internal/server/bluez/types.go | 21 +- core/internal/server/bluez/watcher.go | 2 +- core/internal/server/server.go | 6 +- quickshell/Common/SessionData.qml | 2 + quickshell/Common/settings/SessionSpec.js | 2 + .../ControlCenter/Details/BluetoothDetail.qml | 30 ++- quickshell/Services/BluetoothService.qml | 6 +- 9 files changed, 235 insertions(+), 54 deletions(-) diff --git a/core/internal/server/bluez/handlers.go b/core/internal/server/bluez/handlers.go index 96f71b17d..6298cfb34 100644 --- a/core/internal/server/bluez/handlers.go +++ b/core/internal/server/bluez/handlers.go @@ -50,7 +50,7 @@ func handleGetState(conn *models.Conn, req models.Request, manager *Manager) { } func handleStartDiscovery(conn *models.Conn, req models.Request, manager *Manager) { - if err := manager.StartDiscovery(); err != nil { + if err := manager.StartDiscovery(params.StringOpt(req.Params, "adapter", "")); err != nil { models.RespondError(conn, req.ID, err.Error()) return } @@ -58,7 +58,7 @@ func handleStartDiscovery(conn *models.Conn, req models.Request, manager *Manage } func handleStopDiscovery(conn *models.Conn, req models.Request, manager *Manager) { - if err := manager.StopDiscovery(); err != nil { + if err := manager.StopDiscovery(params.StringOpt(req.Params, "adapter", "")); err != nil { models.RespondError(conn, req.ID, err.Error()) return } @@ -72,7 +72,7 @@ func handleSetPowered(conn *models.Conn, req models.Request, manager *Manager) { return } - if err := manager.SetPowered(powered); err != nil { + if err := manager.SetPowered(params.StringOpt(req.Params, "adapter", ""), powered); err != nil { models.RespondError(conn, req.ID, err.Error()) return } diff --git a/core/internal/server/bluez/manager.go b/core/internal/server/bluez/manager.go index 8964c21b2..0be913bbf 100644 --- a/core/internal/server/bluez/manager.go +++ b/core/internal/server/bluez/manager.go @@ -2,6 +2,7 @@ package bluez import ( "fmt" + "slices" "strings" "sync" "time" @@ -27,6 +28,7 @@ func NewManager() (*Manager, error) { state: &BluetoothState{ Powered: false, Discovering: false, + Adapters: []AdapterInfo{}, Devices: []Device{}, PairedDevices: []Device{}, ConnectedDevices: []Device{}, @@ -43,12 +45,17 @@ func NewManager() (*Manager, error) { broker := NewSubscriptionBroker(m.broadcastPairingPrompt) m.promptBroker = broker - adapter, err := findAdapter(conn) + adapters, err := scanAdapters(conn) if err != nil { conn.Close() - return nil, err + return nil, fmt.Errorf("%w: %v", ErrNoAdapter, err) } - m.adapterPath = adapter + if len(adapters) == 0 { + conn.Close() + return nil, ErrNoAdapter + } + m.adapterPaths = adapters + log.Infof("[BluezManager] adapters: %v (default: %s)", adapters, adapters[0]) if err := m.initialize(); err != nil { conn.Close() @@ -74,22 +81,66 @@ func NewManager() (*Manager, error) { return m, nil } -func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) { +func scanAdapters(conn *dbus.Conn) ([]dbus.ObjectPath, error) { obj := conn.Object(bluezService, dbus.ObjectPath("/")) var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil { - return "", fmt.Errorf("%w: %v", ErrNoAdapter, err) + return nil, err } + paths := []dbus.ObjectPath{} for path, interfaces := range objects { if _, ok := interfaces[adapter1Iface]; ok { - log.Infof("[BluezManager] found adapter: %s", path) - return path, nil + paths = append(paths, path) } } + slices.Sort(paths) + return paths, nil +} - return "", ErrNoAdapter +func (m *Manager) adapterPathsSnapshot() []dbus.ObjectPath { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return append([]dbus.ObjectPath(nil), m.adapterPaths...) +} + +func (m *Manager) resolveAdapter(adapterPath string) (dbus.ObjectPath, error) { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + + if len(m.adapterPaths) == 0 { + return "", ErrNoAdapter + } + if adapterPath == "" { + return m.adapterPaths[0], nil + } + if !slices.Contains(m.adapterPaths, dbus.ObjectPath(adapterPath)) { + return "", fmt.Errorf("unknown adapter: %s", adapterPath) + } + return dbus.ObjectPath(adapterPath), nil +} + +func (m *Manager) refreshAdapters() { + paths, err := scanAdapters(m.dbusConn) + if err != nil { + log.Warnf("[BluezManager] adapter scan failed: %v", err) + return + } + + m.stateMutex.Lock() + changed := !slices.Equal(m.adapterPaths, paths) + m.adapterPaths = paths + m.stateMutex.Unlock() + + if !changed { + return + } + log.Infof("[BluezManager] adapters changed: %v", paths) + if err := m.updateAdapterState(); err != nil { + log.Warnf("[BluezManager] adapter state refresh failed: %v", err) + } + m.notifySubscribers() } func (m *Manager) initialize() error { @@ -105,21 +156,36 @@ func (m *Manager) initialize() error { } func (m *Manager) updateAdapterState() error { - obj := m.dbusConn.Object(bluezService, m.adapterPath) + paths := m.adapterPathsSnapshot() + adapters := make([]AdapterInfo, 0, len(paths)) - poweredVar, err := obj.GetProperty(adapter1Iface + ".Powered") - if err != nil { - return err - } + for _, path := range paths { + obj := m.dbusConn.Object(bluezService, path) - discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering") - if err != nil { - return err + poweredVar, err := obj.GetProperty(adapter1Iface + ".Powered") + if err != nil { + return err + } + discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering") + if err != nil { + return err + } + aliasVar, _ := obj.GetProperty(adapter1Iface + ".Alias") + addressVar, _ := obj.GetProperty(adapter1Iface + ".Address") + + adapters = append(adapters, AdapterInfo{ + Path: string(path), + Name: dbusutil.AsOr(aliasVar, ""), + Address: dbusutil.AsOr(addressVar, ""), + Powered: dbusutil.AsOr(poweredVar, false), + Discovering: dbusutil.AsOr(discoveringVar, false), + }) } m.stateMutex.Lock() - m.state.Powered = dbusutil.AsOr(poweredVar, false) - m.state.Discovering = dbusutil.AsOr(discoveringVar, false) + m.state.Adapters = adapters + m.state.Powered = len(adapters) > 0 && adapters[0].Powered + m.state.Discovering = len(adapters) > 0 && adapters[0].Discovering m.stateMutex.Unlock() return nil @@ -133,6 +199,7 @@ func (m *Manager) updateDevices() error { return err } + adapters := m.adapterPathsSnapshot() devices := []Device{} paired := []Device{} connected := []Device{} @@ -143,7 +210,7 @@ func (m *Manager) updateDevices() error { continue } - if !strings.HasPrefix(string(path), string(m.adapterPath)+"/") { + if !deviceOnAnyAdapter(path, adapters) { continue } @@ -167,6 +234,15 @@ func (m *Manager) updateDevices() error { return nil } +func deviceOnAnyAdapter(path dbus.ObjectPath, adapters []dbus.ObjectPath) bool { + for _, adapter := range adapters { + if strings.HasPrefix(string(path), string(adapter)+"/") { + return true + } + } + return false +} + func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device { return Device{ Path: path, @@ -263,39 +339,82 @@ func (m *Manager) handleSignal(sig *dbus.Signal) { switch iface { case adapter1Iface: - if strings.HasPrefix(string(sig.Path), string(m.adapterPath)) { - m.handleAdapterPropertiesChanged(changed) - } + m.handleAdapterPropertiesChanged(sig.Path, changed) case device1Iface: m.handleDevicePropertiesChanged(sig.Path, changed) } case objectMgrIface + ".InterfacesAdded": + m.maybeRefreshAdapters(sig) m.notifySubscribers() case objectMgrIface + ".InterfacesRemoved": + m.maybeRefreshAdapters(sig) m.notifySubscribers() } } -func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant) { +func (m *Manager) maybeRefreshAdapters(sig *dbus.Signal) { + if !signalTouchesAdapter(sig) { + return + } + select { + case m.eventQueue <- m.refreshAdapters: + default: + } +} + +func signalTouchesAdapter(sig *dbus.Signal) bool { + if len(sig.Body) < 2 { + return false + } + switch ifaces := sig.Body[1].(type) { + case map[string]map[string]dbus.Variant: + _, ok := ifaces[adapter1Iface] + return ok + case []string: + return slices.Contains(ifaces, adapter1Iface) + default: + return false + } +} + +func (m *Manager) handleAdapterPropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) { + powered, hasPowered := dbusutil.Get[bool](changed, "Powered") + discovering, hasDiscovering := dbusutil.Get[bool](changed, "Discovering") + if !hasPowered && !hasDiscovering { + return + } + m.stateMutex.Lock() dirty := false - - if powered, ok := dbusutil.Get[bool](changed, "Powered"); ok { - m.state.Powered = powered + for i := range m.state.Adapters { + if m.state.Adapters[i].Path != string(path) { + continue + } + if hasPowered { + m.state.Adapters[i].Powered = powered + } + if hasDiscovering { + m.state.Adapters[i].Discovering = discovering + } dirty = true } - if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok { - m.state.Discovering = discovering + if len(m.adapterPaths) > 0 && m.adapterPaths[0] == path { + if hasPowered { + m.state.Powered = powered + } + if hasDiscovering { + m.state.Discovering = discovering + } dirty = true } - m.stateMutex.Unlock() - if dirty { - m.notifySubscribers() + if !dirty { + return } + m.notifySubscribers() } func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) { @@ -476,23 +595,35 @@ func (m *Manager) CancelPairing(token string) error { }) } -func (m *Manager) StartDiscovery() error { - obj := m.dbusConn.Object(bluezService, m.adapterPath) +func (m *Manager) StartDiscovery(adapterPath string) error { + path, err := m.resolveAdapter(adapterPath) + if err != nil { + return err + } + obj := m.dbusConn.Object(bluezService, path) return obj.Call(adapter1Iface+".StartDiscovery", 0).Err } -func (m *Manager) StopDiscovery() error { - obj := m.dbusConn.Object(bluezService, m.adapterPath) +func (m *Manager) StopDiscovery(adapterPath string) error { + path, err := m.resolveAdapter(adapterPath) + if err != nil { + return err + } + obj := m.dbusConn.Object(bluezService, path) return obj.Call(adapter1Iface+".StopDiscovery", 0).Err } -func (m *Manager) SetPowered(powered bool) error { +func (m *Manager) SetPowered(adapterPath string, powered bool) error { + path, err := m.resolveAdapter(adapterPath) + if err != nil { + return err + } if powered { if err := rfkillUnblockBluetooth(); err != nil { log.Debugf("[BluezManager] rfkill unblock failed: %v", err) } } - obj := m.dbusConn.Object(bluezService, m.adapterPath) + obj := m.dbusConn.Object(bluezService, path) return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err } @@ -520,7 +651,11 @@ func (m *Manager) DisconnectDevice(devicePath string) error { } func (m *Manager) RemoveDevice(devicePath string) error { - obj := m.dbusConn.Object(bluezService, m.adapterPath) + idx := strings.LastIndex(devicePath, "/") + if idx <= 0 { + return fmt.Errorf("invalid device path: %s", devicePath) + } + obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath[:idx])) return obj.Call(adapter1Iface+".RemoveDevice", 0, dbus.ObjectPath(devicePath)).Err } @@ -569,6 +704,9 @@ func stateChanged(old, new *BluetoothState) bool { if old.Discovering != new.Discovering { return true } + if !slices.Equal(old.Adapters, new.Adapters) { + return true + } if len(old.Devices) != len(new.Devices) { return true } diff --git a/core/internal/server/bluez/types.go b/core/internal/server/bluez/types.go index 54d402fb4..263da6384 100644 --- a/core/internal/server/bluez/types.go +++ b/core/internal/server/bluez/types.go @@ -8,11 +8,20 @@ import ( ) type BluetoothState struct { - Powered bool `json:"powered"` - Discovering bool `json:"discovering"` - Devices []Device `json:"devices"` - PairedDevices []Device `json:"pairedDevices"` - ConnectedDevices []Device `json:"connectedDevices"` + Powered bool `json:"powered"` + Discovering bool `json:"discovering"` + Adapters []AdapterInfo `json:"adapters"` + Devices []Device `json:"devices"` + PairedDevices []Device `json:"pairedDevices"` + ConnectedDevices []Device `json:"connectedDevices"` +} + +type AdapterInfo struct { + Path string `json:"path"` + Name string `json:"name"` + Address string `json:"address"` + Powered bool `json:"powered"` + Discovering bool `json:"discovering"` } type Device struct { @@ -71,7 +80,7 @@ type Manager struct { dirty chan struct{} notifierWg sync.WaitGroup lastNotifiedState *BluetoothState - adapterPath dbus.ObjectPath + adapterPaths []dbus.ObjectPath pendingPairings syncmap.Map[string, bool] eventQueue chan func() eventWg sync.WaitGroup diff --git a/core/internal/server/bluez/watcher.go b/core/internal/server/bluez/watcher.go index 4e86e3054..a7523c618 100644 --- a/core/internal/server/bluez/watcher.go +++ b/core/internal/server/bluez/watcher.go @@ -26,7 +26,7 @@ func WaitForAdapter() error { signals := make(chan *dbus.Signal, 64) conn.Signal(signals) - if _, err := findAdapter(conn); err == nil { + if paths, err := scanAdapters(conn); err == nil && len(paths) > 0 { return nil } diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 6e1c328e4..913d8fee2 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -1427,9 +1427,9 @@ func (s *Server) Serve(printDocs bool) error { log.Info(" theme.auto.subscribe - Subscribe to theme automation state changes (streaming)") log.Info("Bluetooth:") log.Info(" bluetooth.getState - Get current bluetooth state") - log.Info(" bluetooth.startDiscovery - Start device discovery") - log.Info(" bluetooth.stopDiscovery - Stop device discovery") - log.Info(" bluetooth.setPowered - Set adapter power state (params: powered)") + log.Info(" bluetooth.startDiscovery - Start device discovery (params: adapter?)") + log.Info(" bluetooth.stopDiscovery - Stop device discovery (params: adapter?)") + log.Info(" bluetooth.setPowered - Set adapter power state (params: powered, adapter?)") log.Info(" bluetooth.pair - Pair with device (params: device)") log.Info(" bluetooth.connect - Connect to device (params: device)") log.Info(" bluetooth.disconnect - Disconnect from device (params: device)") diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 8e7b76b1f..856be9f94 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -211,6 +211,8 @@ Singleton { property string vpnLastConnected: "" + property string bluetoothAdapterOverride: "" + property string lastPlayerIdentity: "" property var deviceMaxVolumes: ({}) diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js index 15d555493..03136f134 100644 --- a/quickshell/Common/settings/SessionSpec.js +++ b/quickshell/Common/settings/SessionSpec.js @@ -81,6 +81,8 @@ var SPEC = { vpnLastConnected: { def: "" }, + bluetoothAdapterOverride: { def: "" }, + lastPlayerIdentity: { def: "" }, deviceMaxVolumes: { def: {} }, diff --git a/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml b/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml index b44d3edf8..09282eadc 100644 --- a/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml @@ -121,7 +121,35 @@ Rectangle { } Item { - width: Math.max(0, parent.width - headerText.implicitWidth - scanButton.width - Theme.spacingM) + width: Math.max(0, parent.width - headerText.implicitWidth - scanButton.width - Theme.spacingM - (adapterDropdown.visible ? adapterDropdown.width + Theme.spacingS : 0)) + height: parent.height + } + + DankDropdown { + id: adapterDropdown + + function adapterLabel(adapter) { + const name = adapter.name || adapter.adapterId; + const collides = BluetoothService.adapters.some(a => a !== adapter && (a.name || a.adapterId) === name); + return collides ? `${name} (${adapter.adapterId})` : name; + } + + anchors.verticalCenter: parent.verticalCenter + visible: BluetoothService.adapters.length > 1 + compactMode: true + dropdownWidth: 120 + popupWidth: 200 + alignPopupRight: true + options: BluetoothService.adapters.map(a => adapterLabel(a)) + currentValue: BluetoothService.adapter ? adapterLabel(BluetoothService.adapter) : "" + onValueChanged: value => { + const selected = BluetoothService.adapters.find(a => adapterDropdown.adapterLabel(a) === value); + SessionData.set("bluetoothAdapterOverride", selected?.dbusPath ?? ""); + } + } + + Item { + width: adapterDropdown.visible ? Theme.spacingS : 0 height: parent.height } diff --git a/quickshell/Services/BluetoothService.qml b/quickshell/Services/BluetoothService.qml index a22499b10..730c5e37d 100644 --- a/quickshell/Services/BluetoothService.qml +++ b/quickshell/Services/BluetoothService.qml @@ -12,7 +12,8 @@ Singleton { id: root readonly property var log: Log.scoped("BluetoothService") - readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter + readonly property var adapters: Bluetooth.adapters?.values ?? [] + readonly property BluetoothAdapter adapter: adapters.find(a => a.dbusPath === SessionData.bluetoothAdapterOverride) ?? Bluetooth.defaultAdapter readonly property bool available: adapter !== null readonly property bool enabled: (adapter && adapter.enabled) ?? false readonly property bool discovering: (adapter && adapter.discovering) ?? false @@ -83,7 +84,8 @@ Singleton { function setBluetoothEnabled(enabled) { if (DMSService.isConnected && DMSService.capabilities.includes("bluetooth")) { DMSService.sendRequest("bluetooth.setPowered", { - "powered": enabled + "powered": enabled, + "adapter": adapter?.dbusPath ?? "" }, response => { if (response.error) log.warn("Failed to set Bluetooth powered state:", response.error);