diff --git a/src/scripts/monitors/bluez.lua b/src/scripts/monitors/bluez.lua index a369f1e0..0e5ba572 100644 --- a/src/scripts/monitors/bluez.lua +++ b/src/scripts/monitors/bluez.lua @@ -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 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 { Interest { type = "device", + Constraint { "device.api", "=", "bluez5" }, } } @@ -264,30 +259,9 @@ function createNode(parent, id, type, factory, properties) -- sanitize description, replace ':' with ' ' properties["node.description"] = desc:gsub("(:)", " ") + -- set the node name 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"]) .. "." .. tostring(id) @@ -320,13 +294,19 @@ function createNode(parent, id, type, factory, properties) parent:set_managed_pending(id) else 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 - 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 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 + local node = LocalNode("adapter", properties) node:activate(Feature.Proxy.BOUND) parent:store_managed_object(id, node) @@ -336,21 +316,9 @@ end function removeNode(parent, id) local dev_props = parent.properties 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)) - 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 parent:store_managed_object(id + COMBINE_OFFSET, nil) end @@ -424,8 +392,7 @@ function createDevice(parent, id, type, factory, properties) end function removeDevice(parent, id) - sco_source_node_properties[id] = nil - sco_a2dp_sink_node_properties[id] = nil + log:debug("Remove device: " .. tostring (id)) end function createMonitor() @@ -443,7 +410,16 @@ function createMonitor() return monitor 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 { ["capture.props"] = Json.Object { ["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(), {}) 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 { ["capture.props"] = Json.Object { ["node.name"] = string.format ("bluez_output.%s", dev_name), @@ -521,11 +506,6 @@ function checkProfiles (dev) local props = dev.properties 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 local internal_id = tostring (props["spa.object.id"]) local spa_device = monitor:get_managed_object (internal_id) @@ -533,7 +513,7 @@ function checkProfiles (dev) 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 @@ -544,82 +524,61 @@ 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 ({ - "Spa:Pod:Object:Param:Props", - "Props", - params = Pod.Struct ({ "bluez5.autoswitch-routes", true }) - }) - dev:set_param("Props", param) + if has_a2dpsink_profile or has_headset_profile then + local param = Pod.Object ({ + "Spa:Pod:Object:Param:Props", + "Props", + params = Pod.Struct ({ "bluez5.autoswitch-routes", true }) + }) + dev:set_param("Props", param) + end - -- 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 - 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" + if has_headset_profile then + -- Always create the source loopback device if autoswitch is enabled. + -- Otherwise, only create the source loopback device if the current profile + -- is headset, and destroy the source loopback deivce if the current profile + -- is A2DP. + 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) - - -- sanitize description, replace ':' with ' ' - 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) + if is_current_profile_headset 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 + -- Destroy source loopback + spa_device:store_managed_object(LOOPBACK_SOURCE_ID, nil) end end end - local sink_loopback = spa_device:get_managed_object (LOOPBACK_SINK_ID) - if sink_loopback == nil 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" - - log:info("create SCO-A2DP sink loopback node: " .. dev_name) - - -- 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 + if has_a2dpsink_profile or has_headset_profile then + -- Always create sink loopback regardless of the current profile or whether + -- the autoswitch setting is enabled or not. + local sink_loopback = spa_device:get_managed_object (LOOPBACK_SINK_ID) + if sink_loopback == nil then + sink_loopback = CreateDeviceLoopbackSink (props, device_id) + spa_device:store_managed_object(LOOPBACK_SINK_ID, sink_loopback) end end end @@ -627,16 +586,12 @@ end function onDeviceParamsChanged (dev, param_name) if param_name == "EnumProfile" then checkProfiles (dev) + elseif param_name == "Profile" then + checkProfiles (dev) end end 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) checkProfiles (dev) end) @@ -667,3 +622,15 @@ end nodes_om:activate() devices_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 ()