diff --git a/src/scripts/device/autoswitch-bluetooth-profile.lua b/src/scripts/device/autoswitch-bluetooth-profile.lua index e1a80ec7..07f8db39 100644 --- a/src/scripts/device/autoswitch-bluetooth-profile.lua +++ b/src/scripts/device/autoswitch-bluetooth-profile.lua @@ -28,43 +28,25 @@ lutils = require ("linking-utils") cutils = require ("common-utils") log = Log.open_topic ("s-device") +persistent_storage_hooks_registered = false +autoswitch_hooks_registered = false -state = nil -headset_profiles = nil +local PROFILE_RESTORE_TIMEOUT_MSEC = 2000 +local PROFILE_SWITCH_TIMEOUT_MSEC = 500 -local profile_restore_timeout_msec = 2000 -local profile_switch_timeout_msec = 500 - -local INVALID = -1 +local state = nil +local headset_profiles = {} +local non_headset_profiles = {} +local capture_stream_links = {} local restore_timeout_source = {} local switch_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 Properties() - 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) - -function saveHeadsetProfile (device, profile_name) +function saveHeadsetProfile (device, profile_name, persistent) local key = "saved-headset-profile:" .. device.properties ["device.name"] headset_profiles [key] = profile_name - state:save_after_timeout (headset_profiles) + if state ~= nil and persistent then + state:save_after_timeout (headset_profiles) + end end function getSavedHeadsetProfile (device) @@ -72,85 +54,72 @@ function getSavedHeadsetProfile (device) return headset_profiles [key] end -function saveLastProfile (device, profile_name) - last_profiles [device.properties ["device.name"]] = profile_name +function saveNonHeadsetProfile (device, profile_name) + non_headset_profiles [device.properties ["device.name"]] = profile_name end -function getSavedLastProfile (device) - return last_profiles [device.properties ["device.name"]] -end - -function isSwitchedToHeadsetProfile (device) - return getSavedLastProfile (device) ~= nil +function getSavedNonHeadsetProfile (device) + return non_headset_profiles [device.properties ["device.name"]] end 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 - -function getCurrentProfile (device) - for p in device:iterate_params ("Profile") do - local profile = cutils.parseParam (p, "Profile") - if profile then - return profile.name + if profile ~= nil then + if (index ~= nil and profile.index == index) or + (name ~= nil and profile.name == name) then + return profile + end end end return nil end -function highestPrioProfileWithInputRoute (device) - local profile_priority = INVALID - local profile_index = INVALID - local profile_name = nil +function getCurrentProfile (device) + for p in device:iterate_params ("Profile") do + local profile = cutils.parseParam (p, "Profile") + if profile then + return profile + end + end + return nil +end +function highestPrioProfileWithInputRoute (device) + local found_profile = 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 + if route ~= nil and route.profiles ~= nil and route.direction == "Input" 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 + local p = findProfile (device, v) + if p ~= nil then + if found_profile == nil or found_profile.priority < p.priority then + found_profile = p end end end end - - ::skip_enum_route:: end + return found_profile +end - return profile_priority, profile_index, profile_name +function highestPrioProfileWithoutInputRoute (device) + local found_profile = nil + for p in device:iterate_params ("EnumRoute") do + local route = cutils.parseParam (p, "EnumRoute") + if route ~= nil and route.profiles ~= nil and route.direction ~= "Input" then + for _, v in pairs (route.profiles) do + local p = findProfile (device, v) + if p ~= nil then + if found_profile == nil or found_profile.priority < p.priority then + found_profile = p + end + end + end + end + end + return found_profile end function hasProfileInputRoute (device, profile_index) @@ -177,49 +146,40 @@ function switchDeviceToHeadsetProfile (dev_id, device_om) return 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") + -- Do not switch if the current profile is already a headset profile + local cur_profile = getCurrentProfile (device) + if cur_profile ~= nil and + hasProfileInputRoute (device, cur_profile.index) then + log:info (device, + "Current profile is already a headset profile, no need to switch") 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) + -- Get saved headset profile if any, otherwise find the highest priority one + local profile = nil + local profile_name = getSavedHeadsetProfile (device) + if profile_name ~= nil then + profile = findProfile (device, nil, profile_name) + if profile ~= nil and not hasProfileInputRoute (device, profile.index) then + saveHeadsetProfile (device, nil, false) end end - if index == INVALID then - priority, index, name = highestPrioProfileWithInputRoute (device) + if profile == nil then + profile = highestPrioProfileWithInputRoute (device) end - if index ~= INVALID then + -- Switch if headset profile was found + if profile ~= nil then local pod = Pod.Object { "Spa:Pod:Object:Param:Profile", "Profile", - index = index + index = profile.index, + save = false } - - -- 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) + log:info (device, "Switching profile from: " .. cur_profile.name + .. " to: " .. profile.name) device:set_params ("Profile", pod) else - log:warning ("Got invalid index when switching profile") + log:warning ("Could not find valid headset profile, not switching") end end @@ -233,45 +193,40 @@ function restoreProfile (dev_id, device_om) return end - if not isSwitchedToHeadsetProfile (device) then - log:info ("Device with id " .. tostring(dev_id).. " is already not switched to HSP/HFP") + -- Do not restore if the current profile is already a non-headset profile + local cur_profile = getCurrentProfile (device) + if cur_profile ~= nil and + not hasProfileInputRoute (device, cur_profile.index) then + log:info (device, + "Current profile is already a non-headset profile, no need to restore") 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) + -- Get saved non-headset profile if any, otherwise find the highest priority one + local profile = nil + local profile_name = getSavedNonHeadsetProfile (device) + if profile_name ~= nil then + profile = findProfile (device, nil, profile_name) + if profile ~= nil and hasProfileInputRoute (device, profile.index) then + saveNonHeadsetProfile (device, nil) end end + if profile == nil then + profile = highestPrioProfileWithoutInputRoute (device) + 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 + -- Restore if non-headset profile was found + if profile ~= nil then + local pod = Pod.Object { + "Spa:Pod:Object:Param:Profile", "Profile", + index = profile.index, + save = false + } + log:info (device, "Restoring profile from: " .. cur_profile.name + .. " to: " .. profile.name) + device:set_params ("Profile", pod) + else + log:warning ("Could not find valid non-headset profile, not switching") end end @@ -280,95 +235,89 @@ function triggerSwitchDeviceToHeadsetProfile (dev_id, device_om) if restore_timeout_source[dev_id] ~= nil then restore_timeout_source[dev_id]:destroy () restore_timeout_source[dev_id] = nil + log:info ("Cancelled profile restore on device " .. tostring (dev_id)) end if switch_timeout_source[dev_id] ~= nil then switch_timeout_source[dev_id]:destroy () switch_timeout_source[dev_id] = nil + log:info ("Cancelled profile switch on device " .. tostring (dev_id)) end -- create new switch callback - switch_timeout_source[dev_id] = Core.timeout_add (profile_switch_timeout_msec, function () + log:info ("Triggering profile switch on device " .. tostring (dev_id)) + switch_timeout_source[dev_id] = Core.timeout_add (PROFILE_SWITCH_TIMEOUT_MSEC, function () switch_timeout_source[dev_id] = nil switchDeviceToHeadsetProfile (dev_id, device_om) end) end function triggerRestoreProfile (dev_id, device_om) - -- 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 - -- Always clear any pending restore/switch callbacks when triggering a new restore if switch_timeout_source[dev_id] ~= nil then switch_timeout_source[dev_id]:destroy () switch_timeout_source[dev_id] = nil + log:info ("Cancelled profile switch on device " .. tostring (dev_id)) end if restore_timeout_source[dev_id] ~= nil then restore_timeout_source[dev_id]:destroy () restore_timeout_source[dev_id] = nil + log:info ("Cancelled profile restore on device " .. tostring (dev_id)) end -- create new restore callback - restore_timeout_source[dev_id] = Core.timeout_add (profile_restore_timeout_msec, function () + log:info ("Triggering profile restore on device " .. tostring (dev_id)) + restore_timeout_source[dev_id] = Core.timeout_add (PROFILE_RESTORE_TIMEOUT_MSEC, function () restore_timeout_source[dev_id] = nil restoreProfile (dev_id, device_om) end) end --- We consider a Stream of interest if it is linked to a bluetooth loopback --- source filter -function checkStreamStatus (stream, node_om, visited_link_groups) - -- 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 = node_om:lookup { - Constraint { "bound-id", "=", peer_id, type = "gobject" }, - Constraint { "bluez5.loopback", "=", "true", type = "pw" } - } - 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 +function getLinkedBluetoothLoopbackSourceNodeForStream (stream, node_om, link_om, visited_link_groups) + local stream_id = stream["bound-id"] - return dev_id + -- Make sure the node is linked + local link = link_om:lookup { + Constraint { "link.input.node", "=", stream_id, type = "pw-global"} + } + if link == nil then + return nil + end + local peer_id = link.properties["link.output.node"] + + -- If the peer node is the BT loopback source node, return its Id. + -- Otherwise recursively advance in the graph if it is linked to a filter. + local bt_node = node_om:lookup { + Constraint { "media.class", "matches", "Audio/Source", type = "pw-global" }, + Constraint { "bound-id", "=", peer_id, type = "gobject" }, + Constraint { "bluez5.loopback", "=", "true", type = "pw" } + } + if bt_node ~= nil then + return bt_node + else + local filter_main_node = node_om:lookup { + Constraint { "bound-id", "=", peer_id, type = "gobject" }, + Constraint { "node.link-group", "+", type = "pw" } + } + if filter_main_node ~= nil then + local filter_link_group = filter_main_node.properties ["node.link-group"] + if visited_link_groups == nil then + visited_link_groups = {} end - else - -- Check if it is linked to a filter main node, and recursively advance if so - local filter_main_node = node_om:lookup { - Constraint { "bound-id", "=", peer_id, type = "gobject" }, - Constraint { "node.link-group", "+", type = "pw" } - } - if filter_main_node ~= nil then - -- Now check all stream nodes for this filter - local filter_link_group = filter_main_node.properties ["node.link-group"] - if visited_link_groups == nil then - visited_link_groups = {} - end - if visited_link_groups [filter_link_group] then - return nil - else - visited_link_groups [filter_link_group] = true - end - for filter_stream_node in node_om:iterate { - Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, - Constraint { "stream.monitor", "!", "true", type = "pw" }, - Constraint { "bluez5.loopback", "!", "true", type = "pw" }, - Constraint { "node.link-group", "=", filter_link_group, type = "pw" } - } do - local dev_id = checkStreamStatus (filter_stream_node, node_om, visited_link_groups) - if dev_id ~= nil then - return dev_id - end + if visited_link_groups [filter_link_group] then + return nil + else + visited_link_groups [filter_link_group] = true + end + for filter_stream_node in node_om:iterate { + Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, + Constraint { "stream.monitor", "!", "true", type = "pw" }, + Constraint { "bluez5.loopback", "!", "true", type = "pw" }, + Constraint { "node.link-group", "=", filter_link_group, type = "pw" } + } do + local filter_stream_id = filter_stream_node["bound-id"] + local bt_node = getLinkedBluetoothLoopbackSourceNodeForStream (filter_stream_id, node_om, link_om, visited_link_groups) + if bt_node ~= nil then + return bt_node end end end @@ -377,60 +326,65 @@ function checkStreamStatus (stream, node_om, visited_link_groups) return nil end -function handleStream (stream, node_om, device_om) - if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then - return - end - - local dev_id = checkStreamStatus (stream, node_om) - if dev_id ~= nil then - active_streams [stream.id] = dev_id - previous_streams [stream.id] = dev_id - triggerSwitchDeviceToHeadsetProfile (dev_id, device_om) - else - dev_id = active_streams [stream.id] - active_streams [stream.id] = nil - if dev_id ~= nil then - triggerRestoreProfile (dev_id, device_om) - end - end -end - -function handleAllStreams (node_om, device_om) +function isBluetoothLoopbackSourceNodeLinkedToStream (bt_node, node_om, link_om) + local bt_node_id = bt_node["bound-id"] for stream in node_om:iterate { 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" } } do - handleStream (stream, node_om, device_om) + local linked_bt_node = getLinkedBluetoothLoopbackSourceNodeForStream (stream, node_om, link_om) + if linked_bt_node ~= nil then + local linked_bt_node_id = linked_bt_node ["bound-id"] + if tonumber (linked_bt_node_id) == tonumber (bt_node_id) then + return true + end + end end + return false end -SimpleEventHook { - name = "node-removed@autoswitch-bluetooth-profile", +local evaluate_bluetooth_profiles_hook = SimpleEventHook { + name = "evaluate-bluetooth-profiles@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" }, + Constraint { "event.type", "=", "evaluate-bluetooth-profiles" }, }, }, execute = function (event) - local stream = event:get_subject () local source = event:get_source () + local node_om = source:call ("get-object-manager", "node") local device_om = source:call ("get-object-manager", "device") + local link_om = source:call ("get-object-manager", "link") - 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, device_om) + -- Evaluate all bluetooth loopback source nodes, and switch to headset + -- profile only if the node is running and linked to a stream that is not a + -- monitor, otherwise just restore the profile. + -- + -- If the bluetooth node is linked to a stream that is a monitor, its state + -- will be 'running', so we cannot just rely on the state to know if we + -- have to switch or not, we also need to check if the node is linked to + -- a stream that is not a monitor. + for bt_node in node_om:iterate { + Constraint { "media.class", "matches", "Audio/Source" }, + Constraint { "device.id", "+" }, + Constraint { "bluez5.loopback", "=", "true", type = "pw" } + } do + local bt_node_state = bt_node["state"] + local bt_dev_id = bt_node.properties ["device.id"] + + if bt_node_state == "running" and + isBluetoothLoopbackSourceNodeLinkedToStream (bt_node, node_om, link_om) then + triggerSwitchDeviceToHeadsetProfile (bt_dev_id, device_om) + else + triggerRestoreProfile (bt_dev_id, device_om) + end end end -}:register () +} -SimpleEventHook { +local link_added_hook = SimpleEventHook { name = "link-added@autoswitch-bluetooth-profile", interests = { EventInterest { @@ -438,46 +392,149 @@ SimpleEventHook { }, }, execute = function (event) - local link = event:get_subject () local source = event:get_source () local node_om = source:call ("get-object-manager", "node") - local device_om = source:call ("get-object-manager", "device") - local link_props = link.properties + local link = event:get_subject () + local in_stream_id = link.properties["link.input.node"] - for stream in node_om:iterate { + -- Only evaluate bluetooth profiles if a capture stream was linked + local stream = node_om:lookup { 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" } - } do - local in_id = tonumber(link_props["link.input.node"]) - local stream_id = tonumber(stream["bound-id"]) - if in_id == stream_id then - handleStream (stream, node_om, device_om) - end + Constraint { "bluez5.loopback", "!", "true", type = "pw" }, + Constraint { "bound-id", "=", in_stream_id, type = "gobject" }, + } + if stream ~= nil then + capture_stream_links [link.id] = true + source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil) end end -}:register () +} -SimpleEventHook { - name = "bluez-device-added@autoswitch-bluetooth-profile", +local link_removed_hook = SimpleEventHook { + name = "link-removed@autoswitch-bluetooth-profile", interests = { EventInterest { - Constraint { "event.type", "=", "device-added" }, + Constraint { "event.type", "=", "link-removed" }, + }, + }, + execute = function (event) + local source = event:get_source () + local link = event:get_subject () + + -- Only evaluate bluetooth profiles if a capture stream was unlinked + if capture_stream_links [link.id] then + capture_stream_links [link.id] = nil + source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil) + end + end +} + +local state_changed_hook = SimpleEventHook { + name = "bluez-loopback-state-changed@autoswitch-bluetooth-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "node-state-changed" }, + Constraint { "media.class", "matches", "Audio/Source" }, + Constraint { "device.id", "+" }, + Constraint { "bluez5.loopback", "=", "true", type = "pw" } + }, + }, + execute = function (event) + local source = event:get_source () + source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil) + end +} + +local node_added_hook = SimpleEventHook { + name = "bluez-loopback-added@autoswitch-bluetooth-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "node-added" }, + Constraint { "media.class", "matches", "Audio/Source" }, + Constraint { "device.id", "+" }, + Constraint { "bluez5.loopback", "=", "true", type = "pw" } + }, + }, + execute = function (event) + local source = event:get_source () + source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil) + end +} + +local device_profile_changed_hook = SimpleEventHook { + name = "device/store-user-selected-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "=", "Profile" }, Constraint { "device.api", "=", "bluez5" }, }, }, execute = function (event) local device = event:get_subject () - local source = event:get_source () - local node_om = source:call ("get-object-manager", "node") - local device_om = source:call ("get-object-manager", "device") - -- Devices are unswitched initially - saveLastProfile (device, nil) - - -- Handle all streams when BT device is added - handleAllStreams (node_om, device_om) + -- Always save the current profile when it changes + local cur_profile = getCurrentProfile (device) + if cur_profile ~= nil then + if hasProfileInputRoute (device, cur_profile.index) then + log:info (device, "Saving headset profile " .. cur_profile.name) + saveHeadsetProfile (device, cur_profile.name, cur_profile.save) + else + log:info (device, "Saving non-headset profile " .. cur_profile.name) + saveNonHeadsetProfile (device, cur_profile.name) + end + end end -}:register () +} +function evaluatePersistentStorage () + if Settings.get_boolean ("bluetooth.use-persistent-storage") and + not persistent_storage_hooks_registered then + state = State ("bluetooth-autoswitch") + headset_profiles = state:load () + persistent_storage_hooks_registered = true + elseif persistent_storage_hooks_registered then + state = nil + headset_profiles = {} + persistent_storage_hooks_registered = false + end +end + +function evaluateAutoswitch () + if Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") and + not autoswitch_hooks_registered then + capture_stream_links = {} + restore_timeout_source = {} + switch_timeout_source = {} + evaluate_bluetooth_profiles_hook:register () + link_added_hook:register () + link_removed_hook:register () + state_changed_hook:register () + node_added_hook:register () + device_profile_changed_hook:register () + autoswitch_hooks_registered = true + elseif autoswitch_hooks_registered then + capture_stream_links = nil + restore_timeout_source = nil + switch_timeout_source = nil + evaluate_bluetooth_profiles_hook:remove () + link_added_hook:remove () + link_removed_hook:remove () + state_changed_hook:remove () + node_added_hook:remove () + device_profile_changed_hook:remove () + autoswitch_hooks_registered = false + end +end + +Settings.subscribe ("bluetooth.use-persistent-storage", function () + evaluatePersistentStorage () +end) +evaluatePersistentStorage () + +Settings.subscribe ("bluetooth.autoswitch-to-headset-profile", function () + evaluateAutoswitch () +end) +evaluateAutoswitch ()