policy-bluetooth.lua 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. -- WirePlumber
  2. --
  3. -- Copyright © 2021 Asymptotic Inc.
  4. -- @author Sanchayan Maity <sanchayan@asymptotic.io>
  5. --
  6. -- Based on bt-profile-switch.lua in tests/examples
  7. -- Copyright © 2021 George Kiagiadakis
  8. --
  9. -- Based on bluez-autoswitch in media-session
  10. -- Copyright © 2021 Pauli Virtanen
  11. --
  12. -- SPDX-License-Identifier: MIT
  13. --
  14. -- Checks for the existence of media.role and if present switches the bluetooth
  15. -- profile accordingly. Also see bluez-autoswitch in media-session.
  16. -- The intended logic of the script is as follows.
  17. --
  18. -- When a stream comes in, if it has a Communication or phone role in PulseAudio
  19. -- speak in props, we switch to the highest priority profile that has an Input
  20. -- route available. The reason for this is that we may have microphone enabled
  21. -- non-HFP codecs eg. Faststream.
  22. -- We track the incoming streams with Communication role or the applications
  23. -- specified which do not set the media.role correctly perhaps.
  24. -- When a stream goes away if the list with which we track the streams above
  25. -- is empty, then we revert back to the old profile.
  26. local config = ...
  27. local use_persistent_storage = config["use-persistent-storage"] or false
  28. local applications = {}
  29. local use_headset_profile = config["media-role.use-headset-profile"] or false
  30. local profile_restore_timeout_msec = 2000
  31. local INVALID = -1
  32. local timeout_source = nil
  33. local restore_timeout_source = nil
  34. local state = use_persistent_storage and State("policy-bluetooth") or nil
  35. local headset_profiles = state and state:load() or {}
  36. local last_profiles = {}
  37. local active_streams = {}
  38. local previous_streams = {}
  39. for _, value in ipairs(config["media-role.applications"] or {}) do
  40. applications[value] = true
  41. end
  42. metadata_om = ObjectManager {
  43. Interest {
  44. type = "metadata",
  45. Constraint { "metadata.name", "=", "default" },
  46. }
  47. }
  48. devices_om = ObjectManager {
  49. Interest {
  50. type = "device",
  51. Constraint { "device.api", "=", "bluez5" },
  52. }
  53. }
  54. streams_om = ObjectManager {
  55. Interest {
  56. type = "node",
  57. Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
  58. -- Do not consider monitor streams
  59. Constraint { "stream.monitor", "!", "true" }
  60. }
  61. }
  62. local function parseParam(param_to_parse, id)
  63. local param = param_to_parse:parse()
  64. if param.pod_type == "Object" and param.object_id == id then
  65. return param.properties
  66. else
  67. return nil
  68. end
  69. end
  70. local function storeAfterTimeout()
  71. if not use_persistent_storage then
  72. return
  73. end
  74. if timeout_source then
  75. timeout_source:destroy()
  76. end
  77. timeout_source = Core.timeout_add(1000, function ()
  78. local saved, err = state:save(headset_profiles)
  79. if not saved then
  80. Log.warning(err)
  81. end
  82. timeout_source = nil
  83. end)
  84. end
  85. local function saveHeadsetProfile(device, profile_name)
  86. local key = "saved-headset-profile:" .. device.properties["device.name"]
  87. headset_profiles[key] = profile_name
  88. storeAfterTimeout()
  89. end
  90. local function getSavedHeadsetProfile(device)
  91. local key = "saved-headset-profile:" .. device.properties["device.name"]
  92. return headset_profiles[key]
  93. end
  94. local function saveLastProfile(device, profile_name)
  95. last_profiles[device.properties["device.name"]] = profile_name
  96. end
  97. local function getSavedLastProfile(device)
  98. return last_profiles[device.properties["device.name"]]
  99. end
  100. local function isSwitched(device)
  101. return getSavedLastProfile(device) ~= nil
  102. end
  103. local function isBluez5AudioSink(sink_name)
  104. if sink_name and string.find(sink_name, "bluez_output.") ~= nil then
  105. return true
  106. end
  107. return false
  108. end
  109. local function isBluez5DefaultAudioSink()
  110. local metadata = metadata_om:lookup()
  111. local default_audio_sink = metadata:find(0, "default.audio.sink")
  112. return isBluez5AudioSink(default_audio_sink)
  113. end
  114. local function findProfile(device, index, name)
  115. for p in device:iterate_params("EnumProfile") do
  116. local profile = parseParam(p, "EnumProfile")
  117. if not profile then
  118. goto skip_enum_profile
  119. end
  120. Log.debug("Profile name: " .. profile.name .. ", priority: "
  121. .. tostring(profile.priority) .. ", index: " .. tostring(profile.index))
  122. if (index ~= nil and profile.index == index) or
  123. (name ~= nil and profile.name == name) then
  124. return profile.priority, profile.index, profile.name
  125. end
  126. ::skip_enum_profile::
  127. end
  128. return INVALID, INVALID, nil
  129. end
  130. local function getCurrentProfile(device)
  131. for p in device:iterate_params("Profile") do
  132. local profile = parseParam(p, "Profile")
  133. if profile then
  134. return profile.name
  135. end
  136. end
  137. return nil
  138. end
  139. local function highestPrioProfileWithInputRoute(device)
  140. local profile_priority = INVALID
  141. local profile_index = INVALID
  142. local profile_name = nil
  143. for p in device:iterate_params("EnumRoute") do
  144. local route = parseParam(p, "EnumRoute")
  145. -- Parse pod
  146. if not route then
  147. goto skip_enum_route
  148. end
  149. if route.direction ~= "Input" then
  150. goto skip_enum_route
  151. end
  152. Log.debug("Route with index: " .. tostring(route.index) .. ", direction: "
  153. .. route.direction .. ", name: " .. route.name .. ", description: "
  154. .. route.description .. ", priority: " .. route.priority)
  155. if route.profiles then
  156. for _, v in pairs(route.profiles) do
  157. local priority, index, name = findProfile(device, v)
  158. if priority ~= INVALID then
  159. if profile_priority < priority then
  160. profile_priority = priority
  161. profile_index = index
  162. profile_name = name
  163. end
  164. end
  165. end
  166. end
  167. ::skip_enum_route::
  168. end
  169. return profile_priority, profile_index, profile_name
  170. end
  171. local function hasProfileInputRoute(device, profile_index)
  172. for p in device:iterate_params("EnumRoute") do
  173. local route = parseParam(p, "EnumRoute")
  174. if route and route.direction == "Input" and route.profiles then
  175. for _, v in pairs(route.profiles) do
  176. if v == profile_index then
  177. return true
  178. end
  179. end
  180. end
  181. end
  182. return false
  183. end
  184. local function switchProfile()
  185. local index
  186. local name
  187. if restore_timeout_source then
  188. restore_timeout_source:destroy()
  189. restore_timeout_source = nil
  190. end
  191. for device in devices_om:iterate() do
  192. if isSwitched(device) then
  193. goto skip_device
  194. end
  195. local cur_profile_name = getCurrentProfile(device)
  196. saveLastProfile(device, cur_profile_name)
  197. _, index, name = findProfile(device, nil, cur_profile_name)
  198. if hasProfileInputRoute(device, index) then
  199. Log.info("Current profile has input route, not switching")
  200. goto skip_device
  201. end
  202. local saved_headset_profile = getSavedHeadsetProfile(device)
  203. index = INVALID
  204. if saved_headset_profile then
  205. _, index, name = findProfile(device, nil, saved_headset_profile)
  206. end
  207. if index == INVALID then
  208. _, index, name = highestPrioProfileWithInputRoute(device)
  209. end
  210. if index ~= INVALID then
  211. local pod = Pod.Object {
  212. "Spa:Pod:Object:Param:Profile", "Profile",
  213. index = index
  214. }
  215. Log.info("Setting profile of '"
  216. .. device.properties["device.description"]
  217. .. "' from: " .. cur_profile_name
  218. .. " to: " .. name)
  219. device:set_params("Profile", pod)
  220. else
  221. Log.warning("Got invalid index when switching profile")
  222. end
  223. ::skip_device::
  224. end
  225. end
  226. local function restoreProfile()
  227. for device in devices_om:iterate() do
  228. if isSwitched(device) then
  229. local profile_name = getSavedLastProfile(device)
  230. local cur_profile_name = getCurrentProfile(device)
  231. saveLastProfile(device, nil)
  232. if cur_profile_name then
  233. Log.info("Setting saved headset profile to: " .. cur_profile_name)
  234. saveHeadsetProfile(device, cur_profile_name)
  235. end
  236. if profile_name then
  237. local _, index, name = findProfile(device, nil, profile_name)
  238. if index ~= INVALID then
  239. local pod = Pod.Object {
  240. "Spa:Pod:Object:Param:Profile", "Profile",
  241. index = index
  242. }
  243. Log.info("Restoring profile of '"
  244. .. device.properties["device.description"]
  245. .. "' from: " .. cur_profile_name
  246. .. " to: " .. name)
  247. device:set_params("Profile", pod)
  248. else
  249. Log.warning("Failed to restore profile")
  250. end
  251. end
  252. end
  253. end
  254. end
  255. local function triggerRestoreProfile()
  256. if restore_timeout_source then
  257. return
  258. end
  259. if next(active_streams) ~= nil then
  260. return
  261. end
  262. restore_timeout_source = Core.timeout_add(profile_restore_timeout_msec, function ()
  263. restore_timeout_source = nil
  264. restoreProfile()
  265. end)
  266. end
  267. -- We consider a Stream of interest to have role Communication if it has
  268. -- media.role set to Communication in props or it is in our list of
  269. -- applications as these applications do not set media.role correctly or at
  270. -- all.
  271. local function checkStreamStatus(stream)
  272. local app_name = stream.properties["application.name"]
  273. local stream_role = stream.properties["media.role"]
  274. if not (stream_role == "Communication" or applications[app_name]) then
  275. return false
  276. end
  277. if not isBluez5DefaultAudioSink() then
  278. return false
  279. end
  280. -- If a stream we previously saw stops running, we consider it
  281. -- inactive, because some applications (Teams) just cork input
  282. -- streams, but don't close them.
  283. if previous_streams[stream["bound-id"]] and stream.state ~= "running" then
  284. return false
  285. end
  286. return true
  287. end
  288. local function handleStream(stream)
  289. if not use_headset_profile then
  290. return
  291. end
  292. if checkStreamStatus(stream) then
  293. active_streams[stream["bound-id"]] = true
  294. previous_streams[stream["bound-id"]] = true
  295. switchProfile()
  296. else
  297. active_streams[stream["bound-id"]] = nil
  298. triggerRestoreProfile()
  299. end
  300. end
  301. local function handleAllStreams()
  302. for stream in streams_om:iterate {
  303. Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
  304. Constraint { "stream.monitor", "!", "true" }
  305. } do
  306. handleStream(stream)
  307. end
  308. end
  309. streams_om:connect("object-added", function (_, stream)
  310. stream:connect("state-changed", function (stream, old_state, cur_state)
  311. handleStream(stream)
  312. end)
  313. stream:connect("params-changed", handleStream)
  314. handleStream(stream)
  315. end)
  316. streams_om:connect("object-removed", function (_, stream)
  317. active_streams[stream["bound-id"]] = nil
  318. previous_streams[stream["bound-id"]] = nil
  319. triggerRestoreProfile()
  320. end)
  321. devices_om:connect("object-added", function (_, device)
  322. -- Devices are unswitched initially
  323. if isSwitched(device) then
  324. saveLastProfile(device, nil)
  325. end
  326. handleAllStreams()
  327. end)
  328. metadata_om:connect("object-added", function (_, metadata)
  329. metadata:connect("changed", function (m, subject, key, t, value)
  330. if (use_headset_profile and subject == 0 and key == "default.audio.sink"
  331. and isBluez5AudioSink(value)) then
  332. -- If bluez sink is set as default, rescan for active input streams
  333. handleAllStreams()
  334. end
  335. end)
  336. end)
  337. metadata_om:activate()
  338. devices_om:activate()
  339. streams_om:activate()