diff --git a/modules/module-standard-event-source.c b/modules/module-standard-event-source.c index e23696bc..b9414564 100644 --- a/modules/module-standard-event-source.c +++ b/modules/module-standard-event-source.c @@ -157,7 +157,9 @@ static gint get_default_event_priority (const gchar *event_type) { if (g_str_has_prefix(event_type, "select-") || - g_str_has_prefix(event_type, "create-")) + g_str_has_prefix(event_type, "create-") || + g_str_has_prefix(event_type, "remove-") || + g_str_has_prefix(event_type, "evaluate-")) return 500; if (g_str_has_prefix(event_type, "autoswitch-")) return 400; @@ -211,8 +213,10 @@ static gboolean is_it_local_event (const gchar *event_type) { if (g_str_has_prefix(event_type, "select-") || - g_str_has_prefix(event_type, "create-") || - g_str_has_prefix(event_type, "autoswitch-")) + g_str_has_prefix(event_type, "create-") || + g_str_has_prefix(event_type, "autoswitch-") || + g_str_has_prefix(event_type, "remove-") || + g_str_has_prefix(event_type, "evaluate-")) return TRUE; return FALSE; diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index 1f90077e..51785756 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -390,7 +390,7 @@ wireplumber.components = [ requires = [ support.logind ] } - ## Device monitors + ## ALSA monitor { name = monitors/alsa.lua, type = script/lua provides = monitor.alsa @@ -398,13 +398,65 @@ wireplumber.components = [ wants = [ monitor.alsa.reserve-device ] } { - name = monitors/bluez.lua, type = script/lua + name = monitors/alsa-midi.lua, type = script/lua + provides = monitor.alsa-midi + wants = [ monitor.alsa-midi.monitoring ] + } + ## Bluetooth monitor + { + name = monitors/bluez/name-device.lua, type = script/lua + provides = hooks.monitor.bluez-name-device + } + { + name = monitors/bluez/create-device.lua, type = script/lua + provides = hooks.monitor.bluez-create-device + } + { + name = monitors/bluez/evaluate-device-loopbacks.lua, type = script/lua + provides = hooks.monitor.bluez-evaluate-device-loopbacks + } + { + name = monitors/bluez/remove-device.lua, type = script/lua + provides = hooks.monitor.bluez-remove-device + } + { + name = monitors/bluez/name-node.lua, type = script/lua + provides = hooks.monitor.bluez-name-node + } + { + name = monitors/bluez/create-offload-node.lua, type = script/lua + provides = hooks.monitor.bluez-create-offload-node + } + { + name = monitors/bluez/create-set-node.lua, type = script/lua + provides = hooks.monitor.bluez-create-set-node + } + { + name = monitors/bluez/create-node.lua, type = script/lua + provides = hooks.monitor.bluez-create-node + } + { + name = monitors/bluez/remove-node.lua, type = script/lua + provides = hooks.monitor.bluez-remove-node + } + { + name = monitors/bluez/enumerate-device.lua, type = script/lua provides = monitor.bluez requires = [ support.export-core, pw.client-device, pw.client-node, - pw.node-factory.adapter ] - wants = [ monitor.bluez.seat-monitoring ] + pw.node-factory.adapter, + support.standard-event-source, + hooks.monitor.bluez-create-device, + hooks.monitor.bluez-evaluate-device-loopbacks, + hooks.monitor.bluez-remove-device, + hooks.monitor.bluez-create-node, + hooks.monitor.bluez-create-offload-node, + hooks.monitor.bluez-create-set-node, + hooks.monitor.bluez-remove-node ] + wants = [ monitor.bluez.seat-monitoring, + hooks.monitor.bluez-name-device, + hooks.monitor.bluez-name-node ] } { name = monitors/bluez-midi.lua, type = script/lua @@ -415,11 +467,6 @@ wireplumber.components = [ pw.node-factory.spa ] wants = [ monitor.bluez.seat-monitoring ] } - { - name = monitors/alsa-midi.lua, type = script/lua - provides = monitor.alsa-midi - wants = [ monitor.alsa-midi.monitoring ] - } ## v4l2 monitor { name = monitors/v4l2/name-device.lua, type = script/lua diff --git a/src/scripts/lib/monitor-utils.lua b/src/scripts/lib/monitor-utils.lua index 410d495c..0528c0ad 100644 --- a/src/scripts/lib/monitor-utils.lua +++ b/src/scripts/lib/monitor-utils.lua @@ -165,4 +165,10 @@ function mutils.register_cam_node (self, parent, id, factory, properties) end) end +function mutils.get_bluez_node_name (prefix, bt_address, dev_name, node_id) + local name = prefix .. "." .. (bt_address or dev_name) .. "." .. tostring(node_id) + -- sanitize name + return name:gsub("([^%w_%-%.])", "_") +end + return mutils diff --git a/src/scripts/monitors/bluez.lua b/src/scripts/monitors/bluez.lua deleted file mode 100644 index 347e1bc7..00000000 --- a/src/scripts/monitors/bluez.lua +++ /dev/null @@ -1,575 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author George Kiagiadakis --- --- SPDX-License-Identifier: MIT - -COMBINE_OFFSET = 64 -LOOPBACK_SOURCE_ID = 128 -DEVICE_SOURCE_ID = 0 -DEVICE_SINK_ID = 1 - -cutils = require ("common-utils") -log = Log.open_topic ("s-monitors") - -config = {} -config.seat_monitoring = Core.test_feature ("monitor.bluez.seat-monitoring") -config.properties = Conf.get_section_as_properties ("monitor.bluez.properties") -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 - -devices_om = ObjectManager { - Interest { - type = "device", - Constraint { "device.api", "=", "bluez5" }, - } -} - -nodes_om = ObjectManager { - Interest { - type = "node", - Constraint { "node.name", "#", "*.bluez_*put*"}, - Constraint { "device.id", "+" }, - } -} - -function setOffloadActive(device, value) - local pod = Pod.Object { - "Spa:Pod:Object:Param:Props", "Props", bluetoothOffloadActive = value - } - device:set_params("Props", pod) -end - -nodes_om:connect("object-added", function(_, node) - node:connect("state-changed", function(node, old_state, cur_state) - local interest = Interest { - type = "device", - Constraint { "object.id", "=", node.properties["device.id"]} - } - for d in devices_om:iterate (interest) do - if cur_state == "running" then - setOffloadActive(d, true) - else - setOffloadActive(d, false) - end - end - end) -end) - -function createOffloadScoNode(parent, id, type, factory, properties) - local dev_props = parent.properties - - local args = { - ["audio.channels"] = 1, - ["audio.position"] = "[MONO]", - } - - local 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 ' ' - args["node.description"] = desc:gsub("(:)", " ") - - if factory:find("sink") then - local capture_args = { - ["device.id"] = parent["bound-id"], - ["media.class"] = "Audio/Sink", - ["node.pause-on-idle"] = false, - } - for k, v in pairs(properties) do - capture_args[k] = v - end - - local name = "bluez_output" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id) - args["node.name"] = name:gsub("([^%w_%-%.])", "_") - args["capture.props"] = Json.Object(capture_args) - args["playback.props"] = Json.Object { - ["node.passive"] = true, - ["node.pause-on-idle"] = false, - ["state.restore-props"] = false, - } - elseif factory:find("source") then - local playback_args = { - ["device.id"] = parent["bound-id"], - ["media.class"] = "Audio/Source", - ["node.pause-on-idle"] = false, - } - for k, v in pairs(properties) do - playback_args[k] = v - end - - local name = "bluez_input" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id) - args["node.name"] = name:gsub("([^%w_%-%.])", "_") - args["capture.props"] = Json.Object { - ["node.passive"] = true, - ["node.pause-on-idle"] = false, - ["state.restore-props"] = false, - } - args["playback.props"] = Json.Object(playback_args) - else - log:warning(parent, "Unsupported factory: " .. factory) - return - end - - -- Transform 'args' to a json object here - local args_json = Json.Object(args) - - -- and get the final JSON as a string from the json object - local args_string = args_json:get_data() - - local loopback_properties = {} - - local loopback = LocalModule("libpipewire-module-loopback", args_string, loopback_properties) - parent:store_managed_object(id, loopback) -end - -device_set_nodes_om = ObjectManager { - Interest { - type = "node", - Constraint { "api.bluez5.set.leader", "+", type = "pw" }, - } -} - -device_set_nodes_om:connect ("object-added", function(_, node) - -- Connect ObjectConfig events to the right node - if not monitor then - return - end - - local interest = Interest { - type = "device", - Constraint { "object.id", "=", node.properties["device.id"] } - } - log:info("Device set node found: " .. tostring (node["bound-id"])) - for device in devices_om:iterate (interest) do - local device_id = device.properties["spa.object.id"] - if not device_id then - goto next_device - end - - local spa_device = monitor:get_managed_object (tonumber (device_id)) - if not spa_device then - goto next_device - end - - local id = node.properties["card.profile.device"] - if id ~= nil then - log:info(".. assign to device: " .. tostring (device["bound-id"]) .. " node " .. tostring (id)) - spa_device:store_managed_object (id, node) - end - - ::next_device:: - end -end) - -function createSetNode(parent, id, type, factory, properties) - local args = {} - local target_class - local stream_class - local rules = {} - local members_json = Json.Raw (properties["api.bluez5.set.members"]) - local channels_json = Json.Raw (properties["api.bluez5.set.channels"]) - local members = members_json:parse () - local channels = channels_json:parse () - - if properties["media.class"] == "Audio/Sink" then - args["combine.mode"] = "sink" - target_class = "Audio/Sink/Internal" - stream_class = "Stream/Output/Audio/Internal" - else - args["combine.mode"] = "source" - target_class = "Audio/Source/Internal" - stream_class = "Stream/Input/Audio/Internal" - end - - log:info("Device set: " .. properties["node.name"]) - - for _, member in pairs(members) do - log:info("Device set member:" .. member["object.path"]) - table.insert(rules, - Json.Object { - ["matches"] = Json.Array { - Json.Object { - ["object.path"] = member["object.path"], - ["media.class"] = target_class, - }, - }, - ["actions"] = Json.Object { - ["create-stream"] = Json.Object { - ["media.class"] = stream_class, - ["audio.position"] = Json.Array (member["channels"]), - ["state.restore-props"] = false, - } - }, - } - ) - end - - local combine_props = properties:parse () - combine_props["node.virtual"] = false - combine_props["device.api"] = "bluez5" - combine_props["api.bluez5.set.members"] = nil - combine_props["api.bluez5.set.channels"] = nil - combine_props["api.bluez5.set.leader"] = true - combine_props["audio.position"] = Json.Array (channels) - - args["combine.props"] = Json.Object (combine_props) - args["stream.props"] = Json.Object {} - args["stream.rules"] = Json.Array (rules) - - local args_json = Json.Object(args) - local args_string = args_json:get_data() - local combine_properties = {} - log:info("Device set node: " .. args_string) - return LocalModule("libpipewire-module-combine-stream", args_string, combine_properties) -end - -function getNodeName (prefix, bt_address, dev_name, node_id) - local name = prefix .. "." .. (bt_address or dev_name) .. "." .. tostring(node_id) - -- sanitize name - return name:gsub("([^%w_%-%.])", "_") -end - -function createNode(parent, id, type, factory, properties) - local dev_props = parent.properties - local parent_id = parent["bound-id"] - local parent_spa_id = tonumber(dev_props["spa.object.id"]) - - if cutils.parseBool (config.properties ["bluez5.hw-offload-sco"]) and factory:find("sco") then - createOffloadScoNode(parent, id, type, factory, properties) - return - end - - -- set the device id and spa factory name; REQUIRED, do not change - properties["device.id"] = parent_id - properties["factory.name"] = factory - properties["spa.object.id"] = id - - -- set the default pause-on-idle setting - properties["node.pause-on-idle"] = false - - -- set the node description - local 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 ' ' - 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)) - properties["node.name"] = getNodeName (name_prefix, - properties["api.bluez5.address"], dev_props["device.name"], id) - - -- set priority - if not properties["priority.driver"] then - local priority = factory:find("source") and 2010 or 1010 - properties["priority.driver"] = priority - properties["priority.session"] = priority - end - - -- autoconnect if it's a stream - if properties["api.bluez5.profile"] == "headset-audio-gateway" or - properties["api.bluez5.profile"] == "bap-sink" or - factory:find("a2dp.source") or factory:find("media.source") then - properties["node.autoconnect"] = true - end - - -- apply properties from the rules in the configuration file - properties = JsonUtils.match_rules_update_properties (config.rules, properties) - - -- create the node; bluez requires "local" nodes, i.e. ones that run in - -- the same process as the spa device, for several reasons - - if properties["api.bluez5.set.leader"] then - local combine = createSetNode(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)) - - -- 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 - if properties["api.bluez5.profile"] ~= "headset-audio-gateway" then - properties["api.bluez5.internal"] = true - end - end - - local node = LocalNode("adapter", properties) - node:activate(Feature.Proxy.BOUND) - parent:store_managed_object(id, node) - end -end - -function removeNode(parent, id) - local dev_props = parent.properties - local parent_spa_id = tonumber(dev_props["spa.object.id"]) - - log:debug("Remove node: " .. tostring (id)) - - -- Clear also the device set module, if any - parent:store_managed_object(id + COMBINE_OFFSET, nil) -end - -function createDevice(parent, id, type, factory, properties) - local device = parent:get_managed_object(id) - if not device then - -- ensure a proper device name - local name = - (properties["device.name"] or - properties["api.bluez5.address"] or - properties["device.description"] or - tostring(id)):gsub("([^%w_%-%.])", "_") - - if not name:find("^bluez_card%.", 1) then - name = "bluez_card." .. name - end - properties["device.name"] = name - - -- set the icon name - if not properties["device.icon-name"] then - local icon = nil - local icon_map = { - -- form factor -> icon - ["microphone"] = "audio-input-microphone", - ["webcam"] = "camera-web", - ["handset"] = "phone", - ["portable"] = "multimedia-player", - ["tv"] = "video-display", - ["headset"] = "audio-headset", - ["headphone"] = "audio-headphones", - ["speaker"] = "audio-speakers", - ["hands-free"] = "audio-handsfree", - } - local f = properties["device.form-factor"] - local b = properties["device.bus"] - - icon = icon_map[f] or "audio-card" - properties["device.icon-name"] = icon .. (b and ("-" .. b) or "") - end - - -- initial profile is to be set by policy-device-profile.lua, not spa-bluez5 - properties["bluez5.profile"] = "off" - properties["spa.object.id"] = id - - -- apply properties from the rules in the configuration file - properties = JsonUtils.match_rules_update_properties (config.rules, properties) - - -- create the device - device = SpaDevice(factory, properties) - if device then - device:connect("create-object", createNode) - device:connect("object-removed", removeNode) - parent:store_managed_object(id, device) - else - log:warning ("Failed to create '" .. factory .. "' device") - return - end - end - - log:info(parent, string.format("%d, %s (%s): %s", - id, properties["device.description"], - properties["api.bluez5.address"], properties["api.bluez5.connection"])) - - -- activate the device after the bluez profiles are connected - if properties["api.bluez5.connection"] == "connected" then - device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND) - else - device:deactivate(Features.ALL) - end -end - -function removeDevice(parent, id) - log:debug("Remove device: " .. tostring (id)) -end - -function createMonitor() - local monitor = SpaDevice("api.bluez5.enum.dbus", config.properties) - if monitor then - monitor:connect("create-object", createDevice) - monitor:connect("object-removed", removeDevice) - else - log:notice("PipeWire's BlueZ SPA plugin is missing or broken. " .. - "Bluetooth devices will not be supported.") - return nil - end - monitor:activate(Feature.SpaDevice.ENABLED) - - return monitor -end - -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" - local target_object = getNodeName ("bluez_input", - dev_props["api.bluez5.address"], dev_props["device.name"], DEVICE_SOURCE_ID) - - -- 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), - ["media.class"] = "Stream/Input/Audio/Internal", - ["node.description"] = - string.format ("Bluetooth internal capture stream for %s", dec_desc), - ["audio.channels"] = 1, - ["audio.position"] = "[MONO]", - ["bluez5.loopback"] = true, - ["stream.dont-remix"] = true, - ["node.passive"] = true, - ["node.dont-fallback"] = true, - ["node.linger"] = true, - ["state.restore-props"] = false, - ["target.object"] = target_object, - }, - ["playback.props"] = Json.Object { - ["node.name"] = string.format ("bluez_input.%s", dev_name), - ["node.description"] = string.format ("%s", dec_desc), - ["node.virtual"] = false, - ["audio.position"] = "[MONO]", - ["media.class"] = "Audio/Source", - ["device.id"] = dev_id, - ["card.profile.device"] = DEVICE_SOURCE_ID, - ["device.routes"] = "1", - ["priority.session"] = 2010, - ["bluez5.loopback"] = true, - } - } - return LocalModule("libpipewire-module-loopback", args:get_data(), {}) -end - -function checkProfiles (dev) - local device_id = dev["bound-id"] - local props = dev.properties - local device_spa_id = tonumber(props["spa.object.id"]) - - -- Get the associated BT SpaDevice - local internal_id = tostring (props["spa.object.id"]) - local spa_device = monitor:get_managed_object (internal_id) - if spa_device == nil then - return - end - - -- Check if the device supports headset profile - local has_headset_profile = false - for p in dev:iterate_params("EnumProfile") do - local profile = cutils.parseParam (p, "EnumProfile") - if profile.name:find ("headset") then - has_headset_profile = true - end - end - - -- Setup Route/Port correctly for loopback nodes - if 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 - - 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 - - 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 -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) - dev:connect ("params-changed", onDeviceParamsChanged) - checkProfiles (dev) -end) - -if config.seat_monitoring then - logind_plugin = Plugin.find("logind") -end -if logind_plugin then - -- if logind support is enabled, activate - -- the monitor only when the seat is active - function startStopMonitor(seat_state) - log:info(logind_plugin, "Seat state changed: " .. seat_state) - - if seat_state == "active" then - monitor = createMonitor() - elseif monitor then - monitor:deactivate(Feature.SpaDevice.ENABLED) - monitor = nil - end - end - - logind_plugin:connect("state-changed", function(p, s) startStopMonitor(s) end) - startStopMonitor(logind_plugin:call("get-state")) -else - monitor = createMonitor() -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 () diff --git a/src/scripts/monitors/bluez/create-device.lua b/src/scripts/monitors/bluez/create-device.lua new file mode 100644 index 00000000..a675f626 --- /dev/null +++ b/src/scripts/monitors/bluez/create-device.lua @@ -0,0 +1,120 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-monitors-bluez") + +function advanceAfterGlobalDevice (parent, id, source, transition, n_sync) + -- If global device is not found, sync and try again + local device_om = source:call ("get-object-manager", "device") + local device = device_om:lookup { + Constraint { "spa.object.id", "=", id, type = "pw" } + } + if device == nil then + if n_sync > 10 then + transition:return_error ("Could not find BT global device") + else + Core.sync (function () + advanceAfterGlobalDevice (parent, id, source, transition , n_sync + 1) + end) + end + return + end + + -- Finally advance + log:info (parent, "Global device found " .. device:get_property ("device.name")) + transition:advance () +end + +AsyncEventHook { + name = "monitor/bluez/create-device", + after = "monitor/bluez/name-device", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-bluez-device" }, + }, + }, + steps = { + start = { + next = "none", + execute = function(event, transition) + local source = event:get_source () + local properties = event:get_data ("device-properties") + local factory = event:get_data ("factory") + local parent = event:get_subject () + local id = event:get_data ("device-sub-id") + + log:info (parent, "Handling device " .. properties["device.name"]) + + -- Don't do anything if this device is disabled + if properties:get_boolean ("device.disabled") then + log:notice ("Bluez device " .. properties["device.name"] .. " disabled") + transition:advance () + event:stop_processing () + return + end + + -- Create the BT device + local device = SpaDevice (factory, properties) + if device == nil then + log:warning ("Failed to create '" .. factory .. "' device") + transition:advance () + event:stop_processing () + return + end + + -- Handle create-object signal + device:connect ("create-object", function (parent, id, type, factory, properties) + local e = source:call ("create-event", "create-bluez-device-node", parent, nil) + e:set_data ("node-properties", properties) + e:set_data ("type", type) + e:set_data ("factory", factory) + e:set_data ("node-sub-id", id) + EventDispatcher.push_event (e) + end) + + -- Handle object-removed signal + device:connect ("object-removed", function (parent, id) + local e = source:call ("create-event", "remove-bluez-device-node", parent, nil) + e:set_data ("node-sub-id", id) + EventDispatcher.push_event (e) + end) + + -- Store the device as managed object in the monitor + parent:store_managed_object (id, device) + + log:info (parent, string.format("%d, %s (%s): %s", + id, properties["device.description"], + properties["api.bluez5.address"], + properties["api.bluez5.connection"])) + + -- Deactivate the device and stop processing if connection is not 'connected' + if properties["api.bluez5.connection"] ~= "connected" then + log:info (parent, "Not activating device " .. properties["device.name"]) + device:deactivate(Features.ALL) + transition:advance () + event:stop_processing () + return + end + + -- Activate the device + device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND, function (d, e) + if e ~= nil then + transition:return_error ("Failed to activate SPA device " .. + d:get_property ("device.name") .. ": " .. e) + event:stop_processing () + return + end + + log:info (parent, "Activated SPA device " .. properties["device.name"]) + + -- Make sure the global device is created before advancing + advanceAfterGlobalDevice (parent, id, source, transition, 0) + end) + end + } + } +}:register () diff --git a/src/scripts/monitors/bluez/create-node.lua b/src/scripts/monitors/bluez/create-node.lua new file mode 100644 index 00000000..ace28359 --- /dev/null +++ b/src/scripts/monitors/bluez/create-node.lua @@ -0,0 +1,56 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-monitors-bluez") + +AsyncEventHook { + name = "monitor/bluez/create-node", + after = "monitor/bluez/create-set-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-bluez-device-node" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local id = event:get_data ("node-sub-id") + local type = event:get_data ("type") + local factory = event:get_data ("factory") + + log:info (parent, "Handling node " .. properties["node.name"]) + + -- Set sink/source specific properties + if factory == "api.bluez5.sco.source" or + (factory == "api.bluez5.a2dp.source" and properties:get_boolean ("api.bluez5.a2dp-duplex")) then + properties["bluez5.loopback"] = false + if properties["api.bluez5.profile"] ~= "headset-audio-gateway" then + properties["api.bluez5.internal"] = true + end + end + + log:info("Create node: " .. properties["node.name"] .. ": " .. factory .. " " .. tostring (id)) + + -- Create the node + local node = LocalNode("adapter", properties) + node:activate(Feature.Proxy.BOUND, function (n, e) + if e ~= nil then + transition:return_error ("Failed to activate BT node " .. + n:get_property ("node.name") .. ": " .. e) + return + end + + parent:store_managed_object(id, node) + transition:advance () + end) + end + }, + } +}:register () diff --git a/src/scripts/monitors/bluez/create-offload-node.lua b/src/scripts/monitors/bluez/create-offload-node.lua new file mode 100644 index 00000000..ad86fe20 --- /dev/null +++ b/src/scripts/monitors/bluez/create-offload-node.lua @@ -0,0 +1,129 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-monitors-bluez") + +config = {} +config.properties = Conf.get_section_as_properties ("monitor.bluez.properties") + +function createOffloadScoNode(parent, id, type, factory, properties) + local dev_props = parent.properties + + local args = { + ["audio.channels"] = 1, + ["audio.position"] = "[MONO]", + } + + local 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 ' ' + args["node.description"] = desc:gsub("(:)", " ") + + if factory:find("sink") then + local capture_args = { + ["device.id"] = parent["bound-id"], + ["media.class"] = "Audio/Sink", + ["node.pause-on-idle"] = false, + } + for k, v in pairs(properties) do + capture_args[k] = v + end + + local name = "bluez_output" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id) + args["node.name"] = name:gsub("([^%w_%-%.])", "_") + args["capture.props"] = Json.Object(capture_args) + args["playback.props"] = Json.Object { + ["node.passive"] = true, + ["node.pause-on-idle"] = false, + ["state.restore-props"] = false, + } + elseif factory:find("source") then + local playback_args = { + ["device.id"] = parent["bound-id"], + ["media.class"] = "Audio/Source", + ["node.pause-on-idle"] = false, + } + for k, v in pairs(properties) do + playback_args[k] = v + end + + local name = "bluez_input" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id) + args["node.name"] = name:gsub("([^%w_%-%.])", "_") + args["capture.props"] = Json.Object { + ["node.passive"] = true, + ["node.pause-on-idle"] = false, + ["state.restore-props"] = false, + } + args["playback.props"] = Json.Object(playback_args) + else + log:warning(parent, "Unsupported factory: " .. factory) + return false + end + + -- Transform 'args' to a json object here + local args_json = Json.Object(args) + + -- and get the final JSON as a string from the json object + local args_string = args_json:get_data() + + local loopback_properties = {} + + local loopback = LocalModule("libpipewire-module-loopback", args_string, loopback_properties) + parent:store_managed_object(id, loopback) + return true +end + +AsyncEventHook { + name = "monitor/bluez/create-offload-node", + after = "monitor/bluez/name-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-bluez-device-node" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local id = event:get_data ("node-sub-id") + local type = event:get_data ("type") + local factory = event:get_data ("factory") + + log:info (parent, "Handling node " .. properties["node.name"]) + + -- Bypass the hook if offload SCO configuration property is not enabled + if not config.properties:get_boolean ("bluez5.hw-offload-sco") or + not factory:find("sco") then + transition:advance () + return + end + + -- Create the offload SCO loopback nodes module + if not createOffloadScoNode(parent, id, type, factory, properties) then + transition:return_error ( + "Failed to create Offload SCO node for BT node " .. + properties[node.name]) + return + end + + -- FIXME: We should check and wait for the actual offload loopback nodes + -- to be created before finishing + Core.sync (function () + transition:advance () + -- Stop processing any further hooks from this event + event:stop_processing () + end) + end + }, + } +}:register () diff --git a/src/scripts/monitors/bluez/create-set-node.lua b/src/scripts/monitors/bluez/create-set-node.lua new file mode 100644 index 00000000..4d17b7aa --- /dev/null +++ b/src/scripts/monitors/bluez/create-set-node.lua @@ -0,0 +1,148 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +COMBINE_OFFSET = 64 + +log = Log.open_topic ("s-monitors-bluez") + +function createSetNode(parent, id, type, factory, properties) + local args = {} + local target_class + local stream_class + local rules = {} + local members_json = Json.Raw (properties["api.bluez5.set.members"]) + local channels_json = Json.Raw (properties["api.bluez5.set.channels"]) + local members = members_json:parse () + local channels = channels_json:parse () + + if properties["media.class"] == "Audio/Sink" then + args["combine.mode"] = "sink" + target_class = "Audio/Sink/Internal" + stream_class = "Stream/Output/Audio/Internal" + else + args["combine.mode"] = "source" + target_class = "Audio/Source/Internal" + stream_class = "Stream/Input/Audio/Internal" + end + + log:info("Device set: " .. properties["node.name"]) + + for _, member in pairs(members) do + log:info("Device set member:" .. member["object.path"]) + table.insert(rules, + Json.Object { + ["matches"] = Json.Array { + Json.Object { + ["object.path"] = member["object.path"], + ["media.class"] = target_class, + }, + }, + ["actions"] = Json.Object { + ["create-stream"] = Json.Object { + ["media.class"] = stream_class, + ["audio.position"] = Json.Array (member["channels"]), + ["state.restore-props"] = false, + } + }, + } + ) + end + + local combine_props = properties:parse () + combine_props["node.virtual"] = false + combine_props["device.api"] = "bluez5" + combine_props["api.bluez5.set.members"] = nil + combine_props["api.bluez5.set.channels"] = nil + combine_props["api.bluez5.set.leader"] = true + combine_props["audio.position"] = Json.Array (channels) + + args["combine.props"] = Json.Object (combine_props) + args["stream.props"] = Json.Object {} + args["stream.rules"] = Json.Array (rules) + + local args_json = Json.Object(args) + local args_string = args_json:get_data() + local combine_properties = {} + log:info("Device set node: " .. args_string) + return LocalModule("libpipewire-module-combine-stream", args_string, combine_properties) +end + +function advanceAfterSetNodesHandled (parent, combine_id, event, transition, n_sync) + local source = event:get_source () + local device_id = parent["bound-id"] + local node_om = source:call ("get-object-manager", "node") + local nodes_found = false + + for node in node_om:iterate ({ + type = "node", + Constraint { "device.id", "=", device_id }, + Constraint { "api.bluez5.set.leader", "+", type = "pw" }, + }) do + local id = node.properties["card.profile.device"] + log:info (parent, "Storing managed set node " .. tostring (id) .. + " for combine " .. tostring (combine_id)) + parent:store_managed_object (id, node) + nodes_found = true + end + + -- If set nodes were not found, sync and try again + if not nodes_found then + if n_sync > 10 then + transition:return_error ("Could not find global set nodes") + else + Core.sync (function () + advanceAfterSetNodesHandled (parent, combine_id, source, transition , n_sync + 1) + end) + end + return + end + + -- Finally advance + log:info (parent, "Global set nodes handled") + transition:advance () + + -- Stop processing any further hooks from this event + event:stop_processing () +end + +AsyncEventHook { + name = "monitor/bluez/create-set-node", + after = "monitor/bluez/create-offload-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-bluez-device-node" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local id = event:get_data ("node-sub-id") + local type = event:get_data ("type") + local factory = event:get_data ("factory") + + log:info (parent, "Handling node " .. properties["node.name"]) + + -- Bypass the hook if 'api.bluez5.set.leader' property is not set + if properties["api.bluez5.set.leader"] == nil then + transition:advance () + return + end + + -- Create the combine set node + local combine = createSetNode(parent, id, type, factory, properties) + parent:store_managed_object (id + COMBINE_OFFSET, combine) + parent:set_managed_pending (id) + + -- Make sure the global set nodes are handled before advancing + advanceAfterSetNodesHandled (parent, id, event, transition, 0) + end + }, + } +}:register () diff --git a/src/scripts/monitors/bluez/enumerate-device.lua b/src/scripts/monitors/bluez/enumerate-device.lua new file mode 100644 index 00000000..131aecf8 --- /dev/null +++ b/src/scripts/monitors/bluez/enumerate-device.lua @@ -0,0 +1,127 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-monitors-bluez") + +config = {} +config.seat_monitoring = Core.test_feature ("monitor.bluez.seat-monitoring") +config.properties = Conf.get_section_as_properties ("monitor.bluez.properties") + +-- This is not a setting, it must always be enabled +config.properties["api.bluez5.connection-info"] = true + +source = nil +logind_plugin = nil +monitor = nil + +function createBluezDevice (parent, id, type, factory, properties) + source = source or Plugin.find ("standard-event-source") + + local e = source:call ("create-event", "create-bluez-device", parent, nil) + e:set_data ("device-properties", properties) + e:set_data ("factory", factory) + e:set_data ("device-sub-id", id) + + log:info ("BT device " .. tostring (id) .. " connected") + EventDispatcher.push_event (e) +end + +function removeBluezDevice(parent, id) + source = source or Plugin.find ("standard-event-source") + + local e = source:call ("create-event", "remove-bluez-device", parent, nil) + e:set_data ("device-sub-id", id) + + log:info ("BT device " .. tostring (id) .. " connected") + EventDispatcher.push_event (e) +end + +function createMonitor() + -- Create monitor + local m = SpaDevice("api.bluez5.enum.dbus", config.properties) + if m == nil then + log:notice("PipeWire's BlueZ SPA plugin is missing or broken. " .. + "Bluetooth devices will not be supported.") + return nil + end + + -- Handle signals + m:connect("create-object", createBluezDevice) + m:connect("object-removed", removeBluezDevice) + + -- Activate monitor + m:activate (Feature.SpaDevice.ENABLED, function (_, e) + if e ~= nil then + log:warning ("Failed to activate BT monitor: " .. e) + else + log:info ("Created BT monitor") + end + end) + return m +end + +-- Find logind plugin if seat-monitoring is enabled +if config.seat_monitoring then + logind_plugin = Plugin.find("logind") +end + +-- If logind plugin was found, only activate the monitor if the seat is active. +-- Otherwise just activate the monitor +if logind_plugin then + function startStopMonitor(seat_state) + log:info(logind_plugin, "Seat state changed: " .. seat_state) + if seat_state == "active" then + monitor = createMonitor() + elseif monitor then + monitor:deactivate(Feature.SpaDevice.ENABLED) + monitor = nil + end + end + + logind_plugin:connect("state-changed", function(p, s) startStopMonitor(s) end) + startStopMonitor(logind_plugin:call("get-state")) +else + monitor = createMonitor() +end + +-- Evaluate BT device every time it is added or its params have changed +SimpleEventHook { + name = "monitor/bluez/evaluate-bluez-device-trigger", + before = "device/select-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-params-changed"}, + Constraint { "event.subject.param-id", "c", "Profile", "EnumProfile"}, + Constraint { "device.api", "=", "bluez5" }, + }, + EventInterest { + Constraint { "event.type", "=", "device-added"}, + Constraint { "device.api", "=", "bluez5" }, + }, + }, + execute = function (event) + local source = event:get_source () + local device = event:get_subject () + local e = source:call ("create-event", "evaluate-bluez-device", device, nil) + e:set_data ("monitor", monitor) + EventDispatcher.push_event (e) + end +}:register () + +-- Evaluate all BT devices every time the autoswitch setting changes +Settings.subscribe ("bluetooth.autoswitch-to-headset-profile", function () + source = source or Plugin.find ("standard-event-source") + local device_om = source:call ("get-object-manager", "device") + for device in device_om:iterate { + type = "device", + Constraint { "device.api", "=", "bluez5" }, + } do + local e = source:call ("create-event", "evaluate-bluez-device", device, nil) + e:set_data ("monitor", monitor) + EventDispatcher.push_event (e) + end +end) diff --git a/src/scripts/monitors/bluez/evaluate-device-loopbacks.lua b/src/scripts/monitors/bluez/evaluate-device-loopbacks.lua new file mode 100644 index 00000000..194c88f1 --- /dev/null +++ b/src/scripts/monitors/bluez/evaluate-device-loopbacks.lua @@ -0,0 +1,145 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +cutils = require ("common-utils") +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-bluez") + +LOOPBACK_SOURCE_ID = 128 +DEVICE_SOURCE_ID = 0 + +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" + local target_object = mutils.get_bluez_node_name ("bluez_input", + dev_props["api.bluez5.address"], dev_props["device.name"], DEVICE_SOURCE_ID) + + -- 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), + ["media.class"] = "Stream/Input/Audio/Internal", + ["node.description"] = + string.format ("Bluetooth internal capture stream for %s", dec_desc), + ["audio.channels"] = 1, + ["audio.position"] = "[MONO]", + ["bluez5.loopback"] = true, + ["stream.dont-remix"] = true, + ["node.passive"] = true, + ["node.dont-fallback"] = true, + ["node.linger"] = true, + ["state.restore-props"] = false, + ["target.object"] = target_object, + }, + ["playback.props"] = Json.Object { + ["node.name"] = string.format ("bluez_input.%s", dev_name), + ["node.description"] = string.format ("%s", dec_desc), + ["node.virtual"] = false, + ["audio.position"] = "[MONO]", + ["media.class"] = "Audio/Source", + ["device.id"] = dev_id, + ["card.profile.device"] = DEVICE_SOURCE_ID, + ["device.routes"] = "1", + ["priority.session"] = 2010, + ["bluez5.loopback"] = true, + } + } + return LocalModule("libpipewire-module-loopback", args:get_data(), {}) +end + +function evaluateBluezDeviceLoopbacks (parent, dev) + local device_id = dev["bound-id"] + local props = dev.properties + + log:debug (parent, "Checking profiles on BT device: " .. props ["device.name"]) + + local spa_object_id = props:get_int ("spa.object.id") + local spa_dev = parent:get_managed_object (spa_object_id) + if spa_dev == nil then + return + end + + -- Check if the device supports headset profile + local has_headset_profile = false + for p in dev:iterate_params("EnumProfile") do + local profile = cutils.parseParam (p, "EnumProfile") + if profile.name:find ("headset") then + has_headset_profile = true + end + end + + -- Setup Route/Port correctly for loopback nodes + if 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 + + 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_dev:get_managed_object (LOOPBACK_SOURCE_ID) + if source_loopback == nil and has_headset_profile then + source_loopback = CreateDeviceLoopbackSource (props, device_id) + spa_dev: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 + + if is_current_profile_headset then + -- Create source loopback + local source_loopback = spa_dev:get_managed_object (LOOPBACK_SOURCE_ID) + if source_loopback == nil and has_headset_profile then + source_loopback = CreateDeviceLoopbackSource (props, device_id) + spa_dev:store_managed_object(LOOPBACK_SOURCE_ID, source_loopback) + end + else + -- Destroy source loopback + spa_dev:store_managed_object(LOOPBACK_SOURCE_ID, nil) + end + end + end +end + +SimpleEventHook { + name = "monitor/bluez/evaluate-device-loopbacks", + interests = { + EventInterest { + Constraint { "event.type", "=", "evaluate-bluez-device" }, + }, + }, + execute = function (event) + local source = event:get_source () + local device = event:get_subject () + local monitor = event:get_data ("monitor") + + log:info (monitor, "Evaluating device " .. device:get_property ("device.name")) + + evaluateBluezDeviceLoopbacks (monitor, device) + end +}:register () diff --git a/src/scripts/monitors/bluez/name-device.lua b/src/scripts/monitors/bluez/name-device.lua new file mode 100644 index 00000000..5298c4eb --- /dev/null +++ b/src/scripts/monitors/bluez/name-device.lua @@ -0,0 +1,69 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-monitors-bluez") + +config = {} +config.rules = Conf.get_section_as_json ("monitor.bluez.rules", Json.Array {}) + +SimpleEventHook { + name = "monitor/bluez/name-device", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-bluez-device" }, + }, + }, + execute = function(event) + local properties = event:get_data ("device-properties") + local parent = event:get_subject () + local id = event:get_data ("device-sub-id") + + log:info (parent, "Handling device " .. tostring (id)) + + -- ensure a proper device name + local name = + (properties["device.name"] or + properties["api.bluez5.address"] or + properties["device.description"] or + tostring(id)):gsub("([^%w_%-%.])", "_") + if not name:find("^bluez_card%.", 1) then + name = "bluez_card." .. name + end + properties["device.name"] = name + + -- set the icon name + if not properties["device.icon-name"] then + local icon = nil + local icon_map = { + -- form factor -> icon + ["microphone"] = "audio-input-microphone", + ["webcam"] = "camera-web", + ["handset"] = "phone", + ["portable"] = "multimedia-player", + ["tv"] = "video-display", + ["headset"] = "audio-headset", + ["headphone"] = "audio-headphones", + ["speaker"] = "audio-speakers", + ["hands-free"] = "audio-handsfree", + } + local f = properties["device.form-factor"] + local b = properties["device.bus"] + + icon = icon_map[f] or "audio-card" + properties["device.icon-name"] = icon .. (b and ("-" .. b) or "") + end + + -- initial profile is to be set by policy-device-profile.lua, not spa-bluez5 + properties["bluez5.profile"] = "off" + properties["spa.object.id"] = id + + -- apply properties from rules defined in JSON .conf file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + event:set_data ("device-properties", properties) + end +}:register () diff --git a/src/scripts/monitors/bluez/name-node.lua b/src/scripts/monitors/bluez/name-node.lua new file mode 100644 index 00000000..149f62fb --- /dev/null +++ b/src/scripts/monitors/bluez/name-node.lua @@ -0,0 +1,75 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-bluez") + +config = {} +config.rules = Conf.get_section_as_json ("monitor.bluez.rules", Json.Array {}) + +SimpleEventHook { + name = "monitor/bluez/name-node", + before = "monitor/bluez/create-offload-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-bluez-device-node" }, + }, + }, + execute = function(event) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local dev_props = parent.properties + local factory = event:get_data ("factory") + local id = event:get_data ("node-sub-id") + + log:info (parent, "Handling node " .. tostring (id)) + + -- set the device id and spa factory name; REQUIRED, do not change + properties["device.id"] = parent_id + properties["factory.name"] = factory + properties["spa.object.id"] = id + + -- set the default pause-on-idle setting + properties["node.pause-on-idle"] = false + + -- set the node description + local 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 ' ' + 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)) + properties["node.name"] = mutils.get_bluez_node_name (name_prefix, + properties["api.bluez5.address"], dev_props["device.name"], id) + + -- set priority + if not properties["priority.driver"] then + local priority = factory:find("source") and 2010 or 1010 + properties["priority.driver"] = priority + properties["priority.session"] = priority + end + + -- autoconnect if it's a stream + if properties["api.bluez5.profile"] == "headset-audio-gateway" or + properties["api.bluez5.profile"] == "bap-sink" or + factory:find("a2dp.source") or factory:find("media.source") then + properties["node.autoconnect"] = true + end + + -- apply properties from the rules in the configuration file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + event:set_data ("node-properties", properties) + end +}:register () diff --git a/src/scripts/monitors/bluez/remove-device.lua b/src/scripts/monitors/bluez/remove-device.lua new file mode 100644 index 00000000..d231c183 --- /dev/null +++ b/src/scripts/monitors/bluez/remove-device.lua @@ -0,0 +1,22 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-monitors-bluez") + +SimpleEventHook { + name = "monitor/bluez/remove-device", + interests = { + EventInterest { + Constraint { "event.type", "=", "remove-bluez-device" }, + }, + }, + execute = function(event) + local id = event:get_data ("device-sub-id") + + log:debug("Remove device: " .. tostring (id)) + end +}:register () diff --git a/src/scripts/monitors/bluez/remove-node.lua b/src/scripts/monitors/bluez/remove-node.lua new file mode 100644 index 00000000..fae8f6ea --- /dev/null +++ b/src/scripts/monitors/bluez/remove-node.lua @@ -0,0 +1,31 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +COMBINE_OFFSET = 64 + +log = Log.open_topic ("s-monitors-bluez") + +SimpleEventHook { + name = "monitor/bluez/remove-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "remove-bluez-device-node" }, + }, + }, + execute = function(event) + local parent = event:get_subject () + local id = event:get_data ("node-sub-id") + + local dev_props = parent.properties + local parent_spa_id = dev_props:get_int ("spa.object.id") + + log:debug("Remove node: " .. tostring (id)) + + -- Clear also the device set module, if any + parent:store_managed_object(id + COMBINE_OFFSET, nil) + end +}:register ()