-- 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 -- -- This script is charged to automatically change BT profiles on a device. If a -- client is linked to the device's loopback source node, the associated BT -- device profile is automatically switched to HSP/HFP. If there is no clients -- linked to the device's loopback source node, the BT device profile is -- switched back to A2DP profile. -- -- We switch to the highest priority profile that has an Input route available. -- The reason for this is that we may have microphone enabled with non-HFP -- codecs eg. Faststream. -- 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: bluetooth.conf lutils = require ("linking-utils") cutils = require ("common-utils") state = nil headset_profiles = nil device_loopback_sources = {} local profile_restore_timeout_msec = 2000 local INVALID = -1 local timeout_source = {} local restore_timeout_source = {} local last_profiles = {} local active_streams = {} local previous_streams = {} function handlePersistentSetting (enable) if enable and state == nil then -- the state storage state = Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") and State ("bluetooth-autoswitch") or nil headset_profiles = state and state:load () or {} else state = nil headset_profiles = nil end end handlePersistentSetting (Settings.get_boolean ("bluetooth.use-persistent-storage")) Settings.subscribe ("bluetooth.use-persistent-storage", function () handlePersistentSetting (Settings.get_boolean ("bluetooth.use-persistent-storage")) end) 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" }, Constraint { "node.link-group", "-", type = "pw" }, Constraint { "stream.monitor", "!", "true", type = "pw" }, Constraint { "bluez5.loopback", "!", "true", type = "pw" } } } filter_nodes_om = ObjectManager { Interest { type = "node", Constraint { "node.link-group", "+", type = "pw" }, Constraint { "stream.monitor", "!", "true", type = "pw" }, Constraint { "bluez5.loopback", "!", "true", type = "pw" }, } } loopback_nodes_om = ObjectManager { Interest { type = "node", Constraint { "media.class", "matches", "Audio/Source", type = "pw-global" }, Constraint { "node.link-group", "+", type = "pw" }, Constraint { "stream.monitor", "!", "true", type = "pw" }, Constraint { "bluez5.loopback", "=", "true", type = "pw" }, } } local function saveHeadsetProfile (device, profile_name) local key = "saved-headset-profile:" .. device.properties ["device.name"] headset_profiles [key] = profile_name state:save_after_timeout (headset_profiles) 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 isSwitchedToHeadsetProfile (device) return getSavedLastProfile (device) ~= nil end local function findProfile (device, index, name) for p in device:iterate_params ("EnumProfile") do local profile = cutils.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 = cutils.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 = cutils.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 = cutils.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 switchDeviceToHeadsetProfile (dev_id) -- Find the actual device local device = devices_om:lookup { Constraint { "bound-id", "=", dev_id, type = "gobject" } } if device == nil then Log.info ("Device with id " .. tostring(dev_id).. " not found") return end -- clear restore callback, if any if restore_timeout_source[dev_id] ~= nil then restore_timeout_source[dev_id]:destroy () restore_timeout_source[dev_id] = nil end local cur_profile_name = getCurrentProfile (device) local priority, index, name = findProfile (device, nil, cur_profile_name) if hasProfileInputRoute (device, index) then Log.info ("Current profile has input route, not switching") return end if isSwitchedToHeadsetProfile (device) then Log.info ("Device with id " .. tostring(dev_id).. " is already switched to HSP/HFP") return end local saved_headset_profile = getSavedHeadsetProfile (device) index = INVALID if saved_headset_profile then priority, index, name = findProfile (device, nil, saved_headset_profile) if index ~= INVALID and not hasProfileInputRoute (device, index) then index = INVALID saveHeadsetProfile (device, nil) end end if index == INVALID then priority, index, name = highestPrioProfileWithInputRoute (device) end if index ~= INVALID then local pod = Pod.Object { "Spa:Pod:Object:Param:Profile", "Profile", index = index } -- store the current profile (needed when restoring) saveLastProfile (device, cur_profile_name) -- switch to headset profile 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 end local function restoreProfile (dev_id) -- Find the actual device local device = devices_om:lookup { Constraint { "bound-id", "=", dev_id, type = "gobject" } } if device == nil then Log.info ("Device with id " .. tostring(dev_id).. " not found") return end if not isSwitchedToHeadsetProfile (device) then Log.info ("Device with id " .. tostring(dev_id).. " is already not switched to HSP/HFP") return end local profile_name = getSavedLastProfile (device) local cur_profile_name = getCurrentProfile (device) local priority, index, name if cur_profile_name then priority, index, name = findProfile (device, nil, cur_profile_name) if index ~= INVALID and hasProfileInputRoute (device, index) then Log.info ("Setting saved headset profile to: " .. cur_profile_name) saveHeadsetProfile (device, cur_profile_name) end end if profile_name then priority, index, name = findProfile (device, nil, profile_name) if index ~= INVALID then local pod = Pod.Object { "Spa:Pod:Object:Param:Profile", "Profile", index = index } -- clear last profile as we will restore it now saveLastProfile (device, nil) -- restore previous profile 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 local function triggerRestoreProfile (dev_id) -- we never restore the device profiles if there are active streams for _, v in pairs (active_streams) do if v == dev_id then return end end -- clear restore callback, if any if restore_timeout_source[dev_id] ~= nil then restore_timeout_source[dev_id]:destroy () restore_timeout_source[dev_id] = nil end -- create new restore callback restore_timeout_source[dev_id] = Core.timeout_add (profile_restore_timeout_msec, function () restore_timeout_source[dev_id] = nil restoreProfile (dev_id) end) end -- We consider a Stream of interest if it is linked to a bluetooth loopback -- source filter local function checkStreamStatus (stream) -- check if the stream is linked to a bluetooth loopback source local stream_id = tonumber(stream["bound-id"]) local peer_id = lutils.getNodePeerId (stream_id) if peer_id ~= nil then local bt_node = loopback_nodes_om:lookup { Constraint { "bound-id", "=", peer_id, type = "gobject" } } if bt_node ~= nil then local dev_id = bt_node.properties["device.id"] if dev_id ~= nil then -- 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] == dev_id and stream.state ~= "running" then return nil end return dev_id end else -- Check if it is linked to a filter main node, and recursively advance if so local filter_main_node = filter_nodes_om:lookup { Constraint { "bound-id", "=", peer_id, type = "gobject" } } if filter_main_node ~= nil then -- Now check the all stream nodes for this filter local filter_link_group = filter_main_node.properties ["node.link-group"] for filter_stream_node in filter_nodes_om:iterate { Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, Constraint { "node.link-group", "=", filter_link_group, type = "pw" } } do local dev_id = checkStreamStatus (filter_stream_node) if dev_id ~= nil then return dev_id end end end end end return nil end local function handleStream (stream) if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then return end local dev_id = checkStreamStatus (stream) if dev_id ~= nil then active_streams [stream.id] = dev_id previous_streams [stream.id] = dev_id switchDeviceToHeadsetProfile (dev_id) else dev_id = active_streams [stream.id] active_streams [stream.id] = nil if dev_id ~= nil then triggerRestoreProfile (dev_id) end end end local function handleAllStreams () for stream in streams_om:iterate() do handleStream (stream) end end SimpleEventHook { name = "node-removed@autoswitch-bluetooth-profile", interests = { EventInterest { Constraint { "event.type", "=", "node-removed" }, Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, Constraint { "bluez5.loopback", "!", "true", type = "pw" }, }, }, execute = function (event) local stream = event:get_subject () local dev_id = active_streams[stream.id] active_streams[stream.id] = nil previous_streams[stream.id] = nil if dev_id ~= nil then triggerRestoreProfile (dev_id) end end }:register () SimpleEventHook { name = "link-added@autoswitch-bluetooth-profile", interests = { EventInterest { Constraint { "event.type", "=", "link-added" }, }, }, execute = function (event) local link = event:get_subject () local p = link.properties for stream in streams_om:iterate () do local in_id = tonumber(p["link.input.node"]) local stream_id = tonumber(stream["bound-id"]) if in_id == stream_id then handleStream (stream) end end end }:register () SimpleEventHook { name = "bluez-device-added@autoswitch-bluetooth-profile", interests = { EventInterest { Constraint { "event.type", "=", "device-added" }, Constraint { "device.api", "=", "bluez5" }, }, }, execute = function (event) local device = event:get_subject () -- Devices are unswitched initially saveLastProfile (device, nil) handleAllStreams () end }:register () devices_om:activate () streams_om:activate () filter_nodes_om:activate () loopback_nodes_om:activate()