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

Destroying and re-creating the internal A2DP/SCO nodes seem to confuse some
applications when the loopback nodes are emitted too late. Instead of doing
this, these changes delay the creation of the internal BT nodes until the
loopback nodes are fully created, avoiding uneccesary destruction and creation
of nodes.
This commit is contained in:
Julian Bouzas 2026-01-08 13:43:22 -05:00
parent 80842cbb96
commit ccfc6abf65

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