-- WirePlumber -- -- Copyright © 2021 Collabora Ltd. -- @author George Kiagiadakis -- -- SPDX-License-Identifier: MIT COMBINE_OFFSET = 64 LOOPBACK_SOURCE_ID = 128 DEVICE_SOURCE_ID = 0 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", } } 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, } 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, } 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["api.bluez5.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) -- set routes again to update volumes etc. for route in device:iterate_params ("Route") do device:set_param ("Route", route) end 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"]), } }, } ) end properties["node.virtual"] = false properties["device.api"] = "bluez5" properties["api.bluez5.set.members"] = nil properties["api.bluez5.set.channels"] = nil properties["api.bluez5.set.leader"] = true properties["audio.position"] = Json.Array (channels) args["combine.props"] = Json.Object (properties) 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 createNode(parent, id, type, factory, properties) local dev_props = parent.properties local parent_id = parent["bound-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 -- 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("(:)", " ") local name_prefix = ((factory:find("sink") and "bluez_output") or (factory:find("source") and "bluez_input" or factory)) -- hide the source node because we use the loopback source instead if parent:get_managed_object (LOOPBACK_SOURCE_ID) ~= nil and (factory == "api.bluez5.sco.source" or (factory == "api.bluez5.a2dp.source" and cutils.parseBool (properties["api.bluez5.a2dp-duplex"]))) then properties["bluez5.loopback-target"] = true properties["api.bluez5.internal"] = true -- add 'internal' to name prefix to not be confused with loopback node name_prefix = name_prefix .. "_internal" end -- set the node name local name = name_prefix .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id) -- sanitize name properties["node.name"] = name:gsub("([^%w_%-%.])", "_") -- 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) else properties["bluez5.loopback"] = false local node = LocalNode("adapter", properties) node:activate(Feature.Proxy.BOUND) parent:store_managed_object(id, node) end end function removeNode(parent, 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["api.bluez5.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 createMonitor() local monitor = SpaDevice("api.bluez5.enum.dbus", config.properties) if monitor then monitor:connect("create-object", createDevice) 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_name, dec_desc, dev_id) 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 }, ["playback.props"] = Json.Object { ["node.name"] = string.format ("bluez_input.%s", dev_name), ["node.description"] = string.format ("%s", dec_desc), ["audio.position"] = "[MONO]", ["media.class"] = "Audio/Source", ["device.id"] = dev_id, ["card.profile.device"] = DEVICE_SOURCE_ID, ["priority.driver"] = 2010, ["priority.session"] = 2010, ["bluez5.loopback"] = true, ["filter.smart"] = true, ["filter.smart.target"] = Json.Object { ["bluez5.loopback-target"] = true, ["bluez5.loopback"] = false, ["device.id"] = dev_id } } } return LocalModule("libpipewire-module-loopback", args:get_data(), {}) end function checkProfiles (dev) local device_id = dev["bound-id"] local props = dev.properties -- Don't create loopback source device if autoswitch is disabled if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then return end -- Get the associated BT SpaDevice local internal_id = tostring (props["api.bluez5.id"]) local spa_device = monitor:get_managed_object (internal_id) if spa_device == nil then return end -- Ignore devices that don't support both A2DP sink and HSP/HFP profiles local has_a2dpsink_profile = false local has_headset_profile = false for p in dev:iterate_params("EnumProfile") do local profile = cutils.parseParam (p, "EnumProfile") if profile.name:find ("a2dp") and profile.name:find ("sink") then has_a2dpsink_profile = true elseif profile.name:find ("headset") then has_headset_profile = true end end if not has_a2dpsink_profile or not has_headset_profile then return end -- Create the loopback device if never created before local loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID) if loopback == nil then local dev_name = props["api.bluez5.address"] or props["device.name"] local dec_desc = props["device.description"] or props["device.name"] or props["device.nick"] or props["device.alias"] or "bluetooth-device" -- sanitize description, replace ':' with ' ' dec_desc = dec_desc:gsub("(:)", " ") loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id) spa_device:store_managed_object(LOOPBACK_SOURCE_ID, loopback) end end function onDeviceParamsChanged (dev, param_name) if param_name == "EnumProfile" then checkProfiles (dev) end end devices_om:connect("object-added", function(_, dev) -- Ignore all devices that are not BT devices if dev.properties["device.api"] ~= "bluez5" then return end -- check available profiles dev:connect ("params-changed", onDeviceParamsChanged) checkProfiles (dev) end) 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()