mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-18 09:14:01 -04:00
bluetooth: multi-adapter support
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -211,6 +211,8 @@ Singleton {
|
||||
|
||||
property string vpnLastConnected: ""
|
||||
|
||||
property string bluetoothAdapterOverride: ""
|
||||
|
||||
property string lastPlayerIdentity: ""
|
||||
|
||||
property var deviceMaxVolumes: ({})
|
||||
|
||||
@@ -81,6 +81,8 @@ var SPEC = {
|
||||
|
||||
vpnLastConnected: { def: "" },
|
||||
|
||||
bluetoothAdapterOverride: { def: "" },
|
||||
|
||||
lastPlayerIdentity: { def: "" },
|
||||
|
||||
deviceMaxVolumes: { def: {} },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user