monitors/bluez: Refactor and use event hooks to handle BT devices and nodes

This makes the bluez monitor much easier to maintain and add new features. It
follows the same design as the libcamera and v4l2 monitors.

See #916
This commit is contained in:
Julian Bouzas 2026-03-03 16:49:15 -05:00
parent 07e730b279
commit 40d7fa6a8b
14 changed files with 991 additions and 587 deletions

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -1,575 +0,0 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,120 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,56 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,129 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,148 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,127 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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)

View file

@ -0,0 +1,145 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,69 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,75 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,22 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()

View file

@ -0,0 +1,31 @@
-- WirePlumber
--
-- Copyright © 2026 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- 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 ()