monitors/bluez: Always create loopbacks if Device support A2DP and HSP/HFP profiles

This simplifies a lot the logic as we don't need to destroy and re-create the
internal BT nodes right away if the loopback nodes were create after them.

We also now listen for changes in the BT profile autoswitch setting. If the
setting is disabled, the source loopback is destroyed. If it is enabled, the
source loopack is created. This makes the setting to take effect immediately,
without needing to disconnect and re-connect the BT device for the setting to
take effect.
This commit is contained in:
Julian Bouzas 2026-01-23 08:37:27 -05:00 committed by George Kiagiadakis
parent d81b170bbf
commit 4cebb63d76

View file

@ -22,15 +22,10 @@ config.rules = Conf.get_section_as_json ("monitor.bluez.rules", Json.Array {})
-- This is not a setting, it must always be enabled -- This is not a setting, it must always be enabled
config.properties["api.bluez5.connection-info"] = true config.properties["api.bluez5.connection-info"] = true
-- Properties used for previously creating a SCO source node. key: SPA device id
sco_source_node_properties = {}
-- Properties used for previously creating a SCO or A2DP sink node. key: SPA device id
sco_a2dp_sink_node_properties = {}
devices_om = ObjectManager { devices_om = ObjectManager {
Interest { Interest {
type = "device", type = "device",
Constraint { "device.api", "=", "bluez5" },
} }
} }
@ -264,30 +259,9 @@ function createNode(parent, id, type, factory, properties)
-- sanitize description, replace ':' with ' ' -- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ") properties["node.description"] = desc:gsub("(:)", " ")
-- set the node name
local name_prefix = ((factory:find("sink") and "bluez_output") or local name_prefix = ((factory:find("sink") and "bluez_output") or
(factory:find("source") and "bluez_input" or factory)) (factory:find("source") and "bluez_input" or factory))
-- hide the source node because we use the loopback source instead
if parent:get_managed_object (LOOPBACK_SOURCE_ID) ~= nil and
(factory == "api.bluez5.sco.source" or
(factory == "api.bluez5.a2dp.source" and cutils.parseBool (properties["api.bluez5.a2dp-duplex"]))) then
properties["bluez5.loopback-target"] = true
properties["api.bluez5.internal"] = true
-- add 'internal' to name prefix to not be confused with loopback node
name_prefix = name_prefix .. "_internal"
end
-- hide the sink node because we use the loopback sink instead
if parent:get_managed_object (LOOPBACK_SINK_ID) ~= nil and
(factory == "api.bluez5.sco.sink" or
factory == "api.bluez5.a2dp.sink") then
properties["bluez5.sink-loopback-target"] = true
properties["api.bluez5.internal"] = true
-- add 'internal' to name prefix to not be confused with loopback node
name_prefix = name_prefix .. "_internal"
end
-- set the node name
local name = name_prefix .. "." .. local name = name_prefix .. "." ..
(properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." ..
tostring(id) tostring(id)
@ -320,13 +294,19 @@ function createNode(parent, id, type, factory, properties)
parent:set_managed_pending(id) parent:set_managed_pending(id)
else else
log:info("Create node: " .. properties["node.name"] .. ": " .. factory .. " " .. tostring (id)) log:info("Create node: " .. properties["node.name"] .. ": " .. factory .. " " .. tostring (id))
if factory == "api.bluez5.sco.source" then
-- Set sink/source specific properties
if factory == "api.bluez5.sco.source" or
(factory == "api.bluez5.a2dp.source" and cutils.parseBool (properties["api.bluez5.a2dp-duplex"])) then
properties["bluez5.loopback"] = false properties["bluez5.loopback"] = false
sco_source_node_properties[parent_spa_id] = properties properties["bluez5.loopback-target"] = true
properties["api.bluez5.internal"] = true
elseif factory == "api.bluez5.sco.sink" or factory == "api.bluez5.a2dp.sink" then elseif factory == "api.bluez5.sco.sink" or factory == "api.bluez5.a2dp.sink" then
properties["bluez5.sink-loopback"] = false properties["bluez5.sink-loopback"] = false
sco_a2dp_sink_node_properties[parent_spa_id] = properties properties["bluez5.sink-loopback-target"] = true
properties["api.bluez5.internal"] = true
end end
local node = LocalNode("adapter", properties) local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND) node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node) parent:store_managed_object(id, node)
@ -336,21 +316,9 @@ end
function removeNode(parent, id) function removeNode(parent, id)
local dev_props = parent.properties local dev_props = parent.properties
local parent_spa_id = tonumber(dev_props["spa.object.id"]) local parent_spa_id = tonumber(dev_props["spa.object.id"])
local src_properties = sco_source_node_properties[parent_spa_id]
local sink_properties = sco_a2dp_sink_node_properties[parent_spa_id]
log:debug("Remove node: " .. tostring (id)) log:debug("Remove node: " .. tostring (id))
if src_properties ~= nil and id == tonumber(src_properties["spa.object.id"]) then
log:debug("Clear old SCO source properties")
sco_source_node_properties[parent_spa_id] = nil
end
if sink_properties ~= nil and id == tonumber(sink_properties["spa.object.id"]) then
log:debug("Clear old SCO-A2DP sink properties")
sco_a2dp_sink_node_properties[parent_spa_id] = nil
end
-- Clear also the device set module, if any -- Clear also the device set module, if any
parent:store_managed_object(id + COMBINE_OFFSET, nil) parent:store_managed_object(id + COMBINE_OFFSET, nil)
end end
@ -424,8 +392,7 @@ function createDevice(parent, id, type, factory, properties)
end end
function removeDevice(parent, id) function removeDevice(parent, id)
sco_source_node_properties[id] = nil log:debug("Remove device: " .. tostring (id))
sco_a2dp_sink_node_properties[id] = nil
end end
function createMonitor() function createMonitor()
@ -443,7 +410,16 @@ function createMonitor()
return monitor return monitor
end end
function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id) function CreateDeviceLoopbackSource (dev_props, dev_id)
local dev_name = dev_props["api.bluez5.address"] or dev_props["device.name"]
local dec_desc = dev_props["device.description"] or dev_props["device.name"]
or dev_props["device.nick"] or dev_props["device.alias"] or "bluetooth-device"
-- sanitize description, replace ':' with ' '
dec_desc = dec_desc:gsub("(:)", " ")
log:info("create SCO source loopback node: " .. dev_name)
local args = Json.Object { local args = Json.Object {
["capture.props"] = Json.Object { ["capture.props"] = Json.Object {
["node.name"] = string.format ("bluez_capture_internal.%s", dev_name), ["node.name"] = string.format ("bluez_capture_internal.%s", dev_name),
@ -481,7 +457,16 @@ function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id)
return LocalModule("libpipewire-module-loopback", args:get_data(), {}) return LocalModule("libpipewire-module-loopback", args:get_data(), {})
end end
function CreateDeviceLoopbackSink (dev_name, dec_desc, dev_id) function CreateDeviceLoopbackSink (dev_props, dev_id)
local dev_name = dev_props["api.bluez5.address"] or dev_props["device.name"]
local dec_desc = dev_props["device.description"] or dev_props["device.name"]
or dev_props["device.nick"] or dev_props["device.alias"] or "bluetooth-device"
-- sanitize description, replace ':' with ' '
dec_desc = dec_desc:gsub("(:)", " ")
log:info("create SCO-A2DP sink loopback node: " .. dev_name)
local args = Json.Object { local args = Json.Object {
["capture.props"] = Json.Object { ["capture.props"] = Json.Object {
["node.name"] = string.format ("bluez_output.%s", dev_name), ["node.name"] = string.format ("bluez_output.%s", dev_name),
@ -521,11 +506,6 @@ function checkProfiles (dev)
local props = dev.properties local props = dev.properties
local device_spa_id = tonumber(props["spa.object.id"]) local device_spa_id = tonumber(props["spa.object.id"])
-- Don't create loopback source device if autoswitch is disabled
if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then
return
end
-- Get the associated BT SpaDevice -- Get the associated BT SpaDevice
local internal_id = tostring (props["spa.object.id"]) local internal_id = tostring (props["spa.object.id"])
local spa_device = monitor:get_managed_object (internal_id) local spa_device = monitor:get_managed_object (internal_id)
@ -533,7 +513,7 @@ function checkProfiles (dev)
return return
end end
-- Ignore devices that don't support both A2DP sink and HSP/HFP profiles -- Check if the device supports A2DP and HFP/HSP profiles
local has_a2dpsink_profile = false local has_a2dpsink_profile = false
local has_headset_profile = false local has_headset_profile = false
for p in dev:iterate_params("EnumProfile") do for p in dev:iterate_params("EnumProfile") do
@ -544,82 +524,61 @@ function checkProfiles (dev)
has_headset_profile = true has_headset_profile = true
end end
end end
if not has_a2dpsink_profile or not has_headset_profile then
return
end
-- Setup Route/Port correctly for loopback nodes -- Setup Route/Port correctly for loopback nodes
local param = Pod.Object ({ if has_a2dpsink_profile or has_headset_profile then
"Spa:Pod:Object:Param:Props", local param = Pod.Object ({
"Props", "Spa:Pod:Object:Param:Props",
params = Pod.Struct ({ "bluez5.autoswitch-routes", true }) "Props",
}) params = Pod.Struct ({ "bluez5.autoswitch-routes", true })
dev:set_param("Props", param) })
dev:set_param("Props", param)
end
-- Create the source loopback device if never created before if has_headset_profile then
local source_loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID) -- Always create the source loopback device if autoswitch is enabled.
if source_loopback == nil then -- Otherwise, only create the source loopback device if the current profile
local dev_name = props["api.bluez5.address"] or props["device.name"] -- is headset, and destroy the source loopback deivce if the current profile
local dec_desc = props["device.description"] or props["device.name"] -- is A2DP.
or props["device.nick"] or props["device.alias"] or "bluetooth-device" if Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then
-- Create source loopback
local source_loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID)
if source_loopback == nil and has_headset_profile then
source_loopback = CreateDeviceLoopbackSource (props, device_id)
spa_device:store_managed_object(LOOPBACK_SOURCE_ID, source_loopback)
end
else
-- Check if current profile is headset
local is_current_profile_headset = false
for p in dev:iterate_params("Profile") do
local profile = cutils.parseParam (p, "Profile")
if profile.name:find ("headset") then
is_current_profile_headset = true
end
break
end
log:info("create SCO source loopback node: " .. dev_name) if is_current_profile_headset then
-- Create source loopback
-- sanitize description, replace ':' with ' ' local source_loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID)
dec_desc = dec_desc:gsub("(:)", " ") if source_loopback == nil and has_headset_profile then
source_loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id) source_loopback = CreateDeviceLoopbackSource (props, device_id)
spa_device:store_managed_object(LOOPBACK_SOURCE_ID, source_loopback) spa_device:store_managed_object(LOOPBACK_SOURCE_ID, source_loopback)
end
-- recreate any sco source node else
local properties = sco_source_node_properties[device_spa_id] -- Destroy source loopback
if properties ~= nil then spa_device:store_managed_object(LOOPBACK_SOURCE_ID, nil)
local node_id = tonumber(properties["spa.object.id"])
local node = spa_device:get_managed_object (node_id)
if node ~= nil then
log:info("Recreate node: " .. properties["node.name"] .. ": " ..
properties["factory.name"] .. " " .. tostring (node_id))
spa_device:store_managed_object(node_id, nil)
properties["bluez5.loopback-target"] = true
properties["api.bluez5.internal"] = true
node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
spa_device:store_managed_object(node_id, node)
end end
end end
end end
local sink_loopback = spa_device:get_managed_object (LOOPBACK_SINK_ID) if has_a2dpsink_profile or has_headset_profile then
if sink_loopback == nil then -- Always create sink loopback regardless of the current profile or whether
local dev_name = props["api.bluez5.address"] or props["device.name"] -- the autoswitch setting is enabled or not.
local dec_desc = props["device.description"] or props["device.name"] local sink_loopback = spa_device:get_managed_object (LOOPBACK_SINK_ID)
or props["device.nick"] or props["device.alias"] or "bluetooth-device" if sink_loopback == nil then
sink_loopback = CreateDeviceLoopbackSink (props, device_id)
log:info("create SCO-A2DP sink loopback node: " .. dev_name) spa_device:store_managed_object(LOOPBACK_SINK_ID, sink_loopback)
-- sanitize description, replace ':' with ' '
dec_desc = dec_desc:gsub("(:)", " ")
sink_loopback = CreateDeviceLoopbackSink (dev_name, dec_desc, device_id)
spa_device:store_managed_object(LOOPBACK_SINK_ID, sink_loopback)
-- recreate any sco-a2dp sink node
local properties = sco_a2dp_sink_node_properties[device_spa_id]
if properties ~= nil then
local node_id = tonumber(properties["spa.object.id"])
local node = spa_device:get_managed_object (node_id)
if node ~= nil then
log:info("Recreate node: " .. properties["node.name"] .. ": " ..
properties["factory.name"] .. " " .. tostring (node_id))
spa_device:store_managed_object(node_id, nil)
properties["bluez5.sink-loopback-target"] = true
properties["api.bluez5.internal"] = true
node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
spa_device:store_managed_object(node_id, node)
end
end end
end end
end end
@ -627,16 +586,12 @@ end
function onDeviceParamsChanged (dev, param_name) function onDeviceParamsChanged (dev, param_name)
if param_name == "EnumProfile" then if param_name == "EnumProfile" then
checkProfiles (dev) checkProfiles (dev)
elseif param_name == "Profile" then
checkProfiles (dev)
end end
end end
devices_om:connect("object-added", function(_, dev) devices_om:connect("object-added", function(_, dev)
-- Ignore all devices that are not BT devices
if dev.properties["device.api"] ~= "bluez5" then
return
end
-- check available profiles
dev:connect ("params-changed", onDeviceParamsChanged) dev:connect ("params-changed", onDeviceParamsChanged)
checkProfiles (dev) checkProfiles (dev)
end) end)
@ -667,3 +622,15 @@ end
nodes_om:activate() nodes_om:activate()
devices_om:activate() devices_om:activate()
device_set_nodes_om:activate() device_set_nodes_om:activate()
function evaluateAutoswitch ()
-- Evaluate loopbacks on all BT devices
for dev in devices_om:iterate () do
checkProfiles (dev)
end
end
Settings.subscribe ("bluetooth.autoswitch-to-headset-profile", function ()
evaluateAutoswitch ()
end)
evaluateAutoswitch ()