-- WirePlumber -- -- Copyright © 2021 Asymptotic Inc. -- @author Sanchayan Maity -- -- Based on bt-profile-switch.lua in tests/examples -- Copyright © 2021 George Kiagiadakis -- -- Based on bluez-autoswitch in media-session -- Copyright © 2021 Pauli Virtanen -- -- SPDX-License-Identifier: MIT -- -- Scriupt Checks for the existence of media.role and if present switches the -- bluetooth profile accordingly. Also see bluez-autoswitch in media-session. -- The intended logic of the script is as follows. -- -- When a stream comes in, if it has a Communication or phone role in PulseAudio -- speak in props, we switch to the highest priority profile that has an Input -- route available. The reason for this is that we may have microphone enabled -- non-HFP codecs eg. Faststream. -- We track the incoming streams with Communication role or the applications -- specified which do not set the media.role correctly perhaps. -- When a stream goes away if the list with which we track the streams above -- is empty, then we revert back to the old profile. -- settings file: policy.conf local use_persistent_storage = Settings.parse_boolean_safe ("bt-policy-use-persistent-storage", false) local use_headset_profile = Settings.parse_boolean_safe ("bt-policy-media-role.use-headset-profile", true) local applications = {} local profile_restore_timeout_msec = 2000 local INVALID = -1 local timeout_source = nil local restore_timeout_source = nil local state = use_persistent_storage and State ("policy-bluetooth") or nil local headset_profiles = state and state:load () or {} local last_profiles = {} local active_streams = {} local previous_streams = {} local apps_setting = Settings.parse_array_safe ("bt-policy-media-role.applications") for i = 1, #apps_setting do applications [apps_setting [i]] = true end metadata_om = ObjectManager { Interest { type = "metadata", Constraint { "metadata.name", "=", "default" }, } } devices_om = ObjectManager { Interest { type = "device", Constraint { "device.api", "=", "bluez5" }, } } streams_om = ObjectManager { Interest { type = "node", Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, -- Do not consider monitor streams Constraint { "stream.monitor", "!", "true" } } } local function parseParam (param_to_parse, id) local param = param_to_parse:parse () if param.pod_type == "Object" and param.object_id == id then return param.properties else return nil end end local function storeAfterTimeout () if not use_persistent_storage then return end if timeout_source then timeout_source:destroy () end timeout_source = Core.timeout_add (1000, function () local saved, err = state:save (headset_profiles) if not saved then Log.warning (err) end timeout_source = nil end) end local function saveHeadsetProfile (device, profile_name) local key = "saved-headset-profile:" .. device.properties ["device.name"] headset_profiles [key] = profile_name storeAfterTimeout () end local function getSavedHeadsetProfile (device) local key = "saved-headset-profile:" .. device.properties ["device.name"] return headset_profiles [key] end local function saveLastProfile (device, profile_name) last_profiles [device.properties ["device.name"]] = profile_name end local function getSavedLastProfile (device) return last_profiles [device.properties ["device.name"]] end local function isSwitched (device) return getSavedLastProfile (device) ~= nil end local function isBluez5AudioSink (sink_name) if sink_name and string.find (sink_name, "bluez_output.") ~= nil then return true end return false end local function isBluez5DefaultAudioSink () local metadata = metadata_om:lookup () local default_audio_sink = metadata:find (0, "default.audio.sink") return isBluez5AudioSink (default_audio_sink) end local function findProfile (device, index, name) for p in device:iterate_params ("EnumProfile") do local profile = parseParam (p, "EnumProfile") if not profile then goto skip_enum_profile end Log.debug ("Profile name: " .. profile.name .. ", priority: " .. tostring (profile.priority) .. ", index: " .. tostring (profile.index)) if (index ~= nil and profile.index == index) or (name ~= nil and profile.name == name) then return profile.priority, profile.index, profile.name end ::skip_enum_profile:: end return INVALID, INVALID, nil end local function getCurrentProfile (device) for p in device:iterate_params ("Profile") do local profile = parseParam (p, "Profile") if profile then return profile.name end end return nil end local function highestPrioProfileWithInputRoute (device) local profile_priority = INVALID local profile_index = INVALID local profile_name = nil for p in device:iterate_params ("EnumRoute") do local route = parseParam (p, "EnumRoute") -- Parse pod if not route then goto skip_enum_route end if route.direction ~= "Input" then goto skip_enum_route end Log.debug ("Route with index: " .. tostring (route.index) .. ", direction: " .. route.direction .. ", name: " .. route.name .. ", description: " .. route.description .. ", priority: " .. route.priority) if route.profiles then for _, v in pairs (route.profiles) do local priority, index, name = findProfile (device, v) if priority ~= INVALID then if profile_priority < priority then profile_priority = priority profile_index = index profile_name = name end end end end ::skip_enum_route:: end return profile_priority, profile_index, profile_name end local function hasProfileInputRoute (device, profile_index) for p in device:iterate_params ("EnumRoute") do local route = parseParam (p, "EnumRoute") if route and route.direction == "Input" and route.profiles then for _, v in pairs (route.profiles) do if v == profile_index then return true end end end end return false end local function switchProfile () local index local name if restore_timeout_source then restore_timeout_source:destroy () restore_timeout_source = nil end for device in devices_om:iterate () do if isSwitched (device) then goto skip_device end local cur_profile_name = getCurrentProfile (device) saveLastProfile (device, cur_profile_name) _, index, name = findProfile (device, nil, cur_profile_name) if hasProfileInputRoute (device, index) then Log.info ("Current profile has input route, not switching") goto skip_device end local saved_headset_profile = getSavedHeadsetProfile (device) index = INVALID if saved_headset_profile then _, index, name = findProfile (device, nil, saved_headset_profile) end if index == INVALID then _, index, name = highestPrioProfileWithInputRoute (device) end if index ~= INVALID then local pod = Pod.Object { "Spa:Pod:Object:Param:Profile", "Profile", index = index } Log.info ("Setting profile of '" .. device.properties ["device.description"] .. "' from: " .. cur_profile_name .. " to: " .. name) device:set_params ("Profile", pod) else Log.warning ("Got invalid index when switching profile") end ::skip_device:: end end local function restoreProfile () for device in devices_om:iterate () do if isSwitched (device) then local profile_name = getSavedLastProfile (device) local cur_profile_name = getCurrentProfile (device) saveLastProfile (device, nil) if cur_profile_name then Log.info ("Setting saved headset profile to: " .. cur_profile_name) saveHeadsetProfile (device, cur_profile_name) end if profile_name then local _, index, name = findProfile (device, nil, profile_name) if index ~= INVALID then local pod = Pod.Object { "Spa:Pod:Object:Param:Profile", "Profile", index = index } Log.info ("Restoring profile of '" .. device.properties ["device.description"] .. "' from: " .. cur_profile_name .. " to: " .. name) device:set_params ("Profile", pod) else Log.warning ("Failed to restore profile") end end end end end local function triggerRestoreProfile () if restore_timeout_source then return end if next (active_streams) ~= nil then return end restore_timeout_source = Core.timeout_add (profile_restore_timeout_msec, function () restore_timeout_source = nil restoreProfile () end) end -- We consider a Stream of interest to have role Communication if it has -- media.role set to Communication in props or it is in our list of -- applications as these applications do not set media.role correctly or at -- all. local function checkStreamStatus (stream) local app_name = stream.properties ["application.name"] local stream_role = stream.properties ["media.role"] if not (stream_role == "Communication" or applications [app_name]) then return false end if not isBluez5DefaultAudioSink () then return false end -- If a stream we previously saw stops running, we consider it -- inactive, because some applications (Teams) just cork input -- streams, but don't close them. if previous_streams [stream.id] and stream.state ~= "running" then return false end return true end local function handleStream (stream) if not use_headset_profile then return end if checkStreamStatus (stream) then active_streams [stream.id] = true previous_streams [stream.id] = true switchProfile () else active_streams [stream.id] = nil triggerRestoreProfile () end end local function handleAllStreams () for stream in streams_om:iterate { Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, Constraint { "stream.monitor", "!", "true" } } do handleStream (stream) end end SimpleEventHook { name = "input-stream-removed@policy-bluetooth", type = "on-event", priority = "node-removed-policy-bluetooth", interests = { EventInterest { Constraint { "event.type", "=", "object-removed" }, Constraint { "event.subject.type", "=", "node" }, Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, }, }, execute = function (event) stream = event:get_subject () active_streams[stream.id] = nil previous_streams[stream.id] = nil triggerRestoreProfile () end }:register () SimpleEventHook { name = "input-stream-changed@policy-bluetooth", type = "on-event", priority = "node-parms-changed-policy-bluetooth", interests = { EventInterest { Constraint { "event.type", "=", "state-changed" }, Constraint { "event.subject.type", "=", "node" }, Constraint { "media.class", "#", "Stream/Input/Audio", type = "pw-global" }, -- Do not consider monitor streams Constraint { "stream.monitor", "!", "true" } }, EventInterest { Constraint { "event.type", "=", "params-changed" }, Constraint { "event.subject.type", "=", "node" }, Constraint { "media.class", "#", "Stream/Input/Audio", type = "pw-global" }, -- Do not consider monitor streams Constraint { "stream.monitor", "!", "true" } }, }, execute = function (event) handleStream (event:get_subject ()) end }:register () SimpleEventHook { name = "bluez-device-added@policy-bluetooth", type = "on-event", priority = "device-added-policy-bluetooth", interests = { EventInterest { Constraint { "event.type", "=", "object-added" }, Constraint { "event.subject.type", "=", "device" }, Constraint { "device.api", "=", "bluez5" }, }, }, execute = function (event) -- Devices are unswitched initially device = event:get_subject () if isSwitched (device) then saveLastProfile (device, nil) end handleAllStreams () end }:register () SimpleEventHook { name = "metadata-changed@policy-bluetooth", type = "on-event", priority = "default-metadata-changed-policy-bluetooth", interests = { EventInterest { Constraint { "event.type", "=", "object-changed" }, Constraint { "event.subject.type", "=", "metadata" }, Constraint { "metadata.name", "=", "default" }, Constraint { "event.subject.key", "=", "default.audio.sink" }, Constraint { "event.subject.id", "=", "0" }, Constraint { "event.subject.value", "#", "*bluez_output*" }, }, }, execute = function (event) if (use_headset_profile) then -- If bluez sink is set as default, rescan for active input streams handleAllStreams () end end }:register () metadata_om:activate () devices_om:activate () streams_om:activate ()