Merge branch 'bluez-monitor-improvements' into 'master'

monitors/bluez: Avoid recreating A2DP/SCO nodes if loopback is emitted late

See merge request pipewire/wireplumber!778
This commit is contained in:
Julian Bouzas 2026-01-22 14:07:45 +00:00
commit 20bda1d76c

View file

@ -22,12 +22,14 @@ config.rules = Conf.get_section_as_json ("monitor.bluez.rules", Json.Array {})
-- This is not a setting, it must always be enabled
config.properties["api.bluez5.connection-info"] = true
-- Properties used for previously creating a SCO source node. key: SPA device id
-- Properties used for creating delayed SCO/A2DP nodes. 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 = {}
-- Boolean used know if loopbacks are ready or not. key: SPA device id
source_loopback_ready = {}
sink_loopback_ready = {}
devices_om = ObjectManager {
Interest {
type = "device",
@ -265,26 +267,6 @@ function createNode(parent, id, type, factory, properties)
local name_prefix = ((factory:find("sink") and "bluez_output") or
(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 .. "." ..
(properties["api.bluez5.address"] or dev_props["device.name"]) .. "." ..
@ -317,20 +299,153 @@ function createNode(parent, id, type, factory, properties)
parent:store_managed_object(id + COMBINE_OFFSET, combine)
parent:set_managed_pending(id)
else
log:info("Create node: " .. properties["node.name"] .. ": " .. factory .. " " .. tostring (id))
if factory == "api.bluez5.sco.source" then
properties["bluez5.loopback"] = false
sco_source_node_properties[parent_spa_id] = properties
elseif factory == "api.bluez5.sco.sink" or factory == "api.bluez5.a2dp.sink" then
properties["bluez5.sink-loopback"] = false
sco_a2dp_sink_node_properties[parent_spa_id] = properties
local create_node = true
-- If autoswitch is enabled, add the loopback properties and check if
-- we can create the node right away or not
if Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then
-- Delay node creation until the associated loopback node is created
if 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
properties["bluez5.loopback"] = false
-- Delay node creation until the associated loopback node is created
if source_loopback_ready [parent_spa_id] == nil or
not source_loopback_ready [parent_spa_id] then
sco_source_node_properties[parent_spa_id] = properties
create_node = false
end
elseif factory == "api.bluez5.sco.sink" or factory == "api.bluez5.a2dp.sink" then
properties["bluez5.sink-loopback-target"] = true
properties["api.bluez5.internal"] = true
properties["bluez5.sink-loopback"] = false
-- Delay node creation until the associated loopback node is created
if sink_loopback_ready [parent_spa_id] == nil or
not sink_loopback_ready [parent_spa_id] then
sco_a2dp_sink_node_properties[parent_spa_id] = properties
create_node = false
end
end
end
-- Create the node only if autoswitch is disable or the associated loopback
-- node is ready. Otherwise, the node creation will be delayed until the
-- associated loopback is ready
if create_node then
log:info("Create node: " .. properties["node.name"] .. ": " ..
factory .. " " .. tostring (id))
local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
else
log:info("Delay node: " .. properties["node.name"] .. ": " ..
factory .. " " .. tostring (id))
end
local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
end
end
SimpleEventHook {
name = "monitors/bluez/create-internal-source-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "=", "Audio/Source" },
Constraint { "node.link-group", "+", type = "pw" },
Constraint { "bluez5.loopback", "=", "true", type = "pw" },
},
},
execute = function (event)
local source = event:get_source ()
local device_om = source:call ("get-object-manager", "device")
local node = event:get_subject ()
local dev_id = node.properties:get_int ("device.id")
-- Get the associated device
local device = device_om:lookup {
Constraint { "bound-id", "=", dev_id, type = "gobject" }
}
if device == nil then
log:info ("Could not find device for source loopback node")
return
end
-- Get the associated spa device
local device_spa_id = device.properties:get_int ("spa.object.id")
local spa_device = monitor:get_managed_object (device_spa_id)
if spa_device == nil then
log:info ("Could not find SPA device for source loopback node")
return
end
-- Mark source loopback as ready
source_loopback_ready [device_spa_id] = true
-- Create the internal node
local properties = sco_source_node_properties[device_spa_id]
if properties ~= nil then
local node_id = tonumber(properties["spa.object.id"])
log:info("Create node: " .. properties["node.name"] .. ": " ..
properties["factory.name"] .. " " .. tostring (node_id))
node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
spa_device:store_managed_object(node_id, node)
end
end
}:register()
SimpleEventHook {
name = "monitors/bluez/create-internal-sink-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "=", "Audio/Sink" },
Constraint { "node.link-group", "+", type = "pw" },
Constraint { "bluez5.sink-loopback", "=", "true", type = "pw" },
},
},
execute = function (event)
local source = event:get_source ()
local device_om = source:call ("get-object-manager", "device")
local node = event:get_subject ()
local dev_id = node.properties:get_int ("device.id")
-- Get the associated device
local device = device_om:lookup {
Constraint { "bound-id", "=", dev_id, type = "gobject" }
}
if device == nil then
log:info ("Could not find device for sink loopback node")
return
end
-- Get the associated spa device
local device_spa_id = device.properties:get_int ("spa.object.id")
local spa_device = monitor:get_managed_object (device_spa_id)
if spa_device == nil then
log:info ("Could not find SPA device for sink loopback node")
return
end
-- Mark sink loopback as ready
sink_loopback_ready [device_spa_id] = true
-- create the internal node
local properties = sco_a2dp_sink_node_properties[device_spa_id]
if properties ~= nil then
local node_id = tonumber(properties["spa.object.id"])
log:info("Create node: " .. properties["node.name"] .. ": " ..
properties["factory.name"] .. " " .. tostring (node_id))
node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
spa_device:store_managed_object(node_id, node)
end
end
}:register()
function removeNode(parent, id)
local dev_props = parent.properties
local parent_spa_id = tonumber(dev_props["spa.object.id"])
@ -424,6 +539,8 @@ end
function removeDevice(parent, id)
sco_source_node_properties[id] = nil
sco_a2dp_sink_node_properties[id] = nil
source_loopback_ready[id] = nil
sink_loopback_ready[id] = nil
end
function createMonitor()
@ -525,13 +642,12 @@ function checkProfiles (dev)
end
-- Get the associated BT SpaDevice
local internal_id = tostring (props["spa.object.id"])
local spa_device = monitor:get_managed_object (internal_id)
local spa_device = monitor:get_managed_object (device_spa_id)
if spa_device == nil then
return
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_headset_profile = false
for p in dev:iterate_params("EnumProfile") do
@ -542,9 +658,6 @@ function checkProfiles (dev)
has_headset_profile = true
end
end
if not has_a2dpsink_profile or not has_headset_profile then
return
end
-- Setup Route/Port correctly for loopback nodes
local param = Pod.Object ({
@ -556,7 +669,7 @@ function checkProfiles (dev)
-- Create the source loopback device if never created before
local source_loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID)
if source_loopback == nil then
if source_loopback == nil and has_headset_profile then
local dev_name = props["api.bluez5.address"] or props["device.name"]
local dec_desc = props["device.description"] or props["device.name"]
or props["device.nick"] or props["device.alias"] or "bluetooth-device"
@ -567,29 +680,10 @@ function checkProfiles (dev)
dec_desc = dec_desc:gsub("(:)", " ")
source_loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id)
spa_device:store_managed_object(LOOPBACK_SOURCE_ID, source_loopback)
-- recreate any sco source node
local properties = sco_source_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.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
local sink_loopback = spa_device:get_managed_object (LOOPBACK_SINK_ID)
if sink_loopback == nil then
if sink_loopback == nil and (has_a2dpsink_profile or has_headset_profile) then
local dev_name = props["api.bluez5.address"] or props["device.name"]
local dec_desc = props["device.description"] or props["device.name"]
or props["device.nick"] or props["device.alias"] or "bluetooth-device"
@ -600,25 +694,6 @@ function checkProfiles (dev)
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