mirror of
https://gitlab.freedesktop.org/pipewire/wireplumber.git
synced 2026-05-09 02:48:05 +02:00
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:
parent
07e730b279
commit
40d7fa6a8b
14 changed files with 991 additions and 587 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ()
|
||||
120
src/scripts/monitors/bluez/create-device.lua
Normal file
120
src/scripts/monitors/bluez/create-device.lua
Normal 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 ()
|
||||
56
src/scripts/monitors/bluez/create-node.lua
Normal file
56
src/scripts/monitors/bluez/create-node.lua
Normal 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 ()
|
||||
129
src/scripts/monitors/bluez/create-offload-node.lua
Normal file
129
src/scripts/monitors/bluez/create-offload-node.lua
Normal 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 ()
|
||||
148
src/scripts/monitors/bluez/create-set-node.lua
Normal file
148
src/scripts/monitors/bluez/create-set-node.lua
Normal 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 ()
|
||||
127
src/scripts/monitors/bluez/enumerate-device.lua
Normal file
127
src/scripts/monitors/bluez/enumerate-device.lua
Normal 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)
|
||||
145
src/scripts/monitors/bluez/evaluate-device-loopbacks.lua
Normal file
145
src/scripts/monitors/bluez/evaluate-device-loopbacks.lua
Normal 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 ()
|
||||
69
src/scripts/monitors/bluez/name-device.lua
Normal file
69
src/scripts/monitors/bluez/name-device.lua
Normal 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 ()
|
||||
75
src/scripts/monitors/bluez/name-node.lua
Normal file
75
src/scripts/monitors/bluez/name-node.lua
Normal 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 ()
|
||||
22
src/scripts/monitors/bluez/remove-device.lua
Normal file
22
src/scripts/monitors/bluez/remove-device.lua
Normal 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 ()
|
||||
31
src/scripts/monitors/bluez/remove-node.lua
Normal file
31
src/scripts/monitors/bluez/remove-node.lua
Normal 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 ()
|
||||
Loading…
Add table
Reference in a new issue