New wireplumber config

This commit is contained in:
2024-07-26 06:23:40 -04:00
parent f9f2119beb
commit 9716a8df5a
99 changed files with 7572 additions and 5398 deletions

View File

@@ -0,0 +1,59 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
-- apply the selected profile to the device
cutils = require ("common-utils")
log = Log.open_topic ("s-device")
AsyncEventHook {
name = "device/apply-profile",
after = { "device/find-stored-profile", "device/find-preferred-profile", "device/find-best-profile" },
interests = {
EventInterest {
Constraint { "event.type", "=", "select-profile" },
},
},
steps = {
start = {
next = "none",
execute = function (event, transition)
local device = event:get_subject ()
local profile = event:get_data ("selected-profile")
local dev_name = device.properties ["device.name"] or ""
if not profile then
log:info (device, "No profile found to set on " .. dev_name)
transition:advance ()
return
end
for p in device:iterate_params ("Profile") do
local active_profile = cutils.parseParam (p, "Profile")
if active_profile.index == tonumber(profile.index) then
log:info (device, "Profile " .. profile.name .. " is already set on " .. dev_name)
transition:advance ()
return
end
end
local param = Pod.Object {
"Spa:Pod:Object:Param:Profile", "Profile",
index = tonumber(profile.index),
}
log:info (device, "Setting profile " .. profile.name .. " on " .. dev_name)
device:set_param ("Profile", param)
-- FIXME: add cancellability
-- sync on the pipewire connection to ensure that the param
-- has been configured on the remote device object
Core.sync (function ()
transition:advance ()
end)
end
},
}
}:register()

View File

@@ -0,0 +1,107 @@
-- WirePlumber
--
-- Copyright © 2021-2022 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- Based on default-routes.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
--
-- Set the Route param as part of the "select-routes" event run
devinfo = require ("device-info-cache")
log = Log.open_topic ("s-device")
AsyncEventHook {
name = "device/apply-routes",
after = { "device/find-stored-routes",
"device/find-best-routes",
"device/apply-route-props" },
interests = {
EventInterest {
Constraint { "event.type", "=", "select-routes" },
},
},
steps = {
start = {
next = "none",
execute = function (event, transition)
local device = event:get_subject ()
local selected_routes = event:get_data ("selected-routes")
local dev_info = devinfo:get_device_info (device)
assert (dev_info)
if not selected_routes then
log:info (device, "No routes selected to set on " .. dev_info.name)
transition:advance ()
return
end
for device_id, route in pairs (selected_routes) do
-- JSON to lua table
route = Json.Raw (route):parse ()
-- steal the props
local props = route.props or {}
-- replace with the full route info
local route_info = devinfo.find_route_info (dev_info, route)
if not route_info then
goto skip_route
end
-- ensure default values
local is_input = (route_info.direction == "Input")
props.mute = props.mute or false
props.channelVolumes = props.channelVolumes or
{ is_input and Settings.get_float ("device.routes.default-source-volume")
or Settings.get_float ("device.routes.default-sink-volume") }
-- prefix the props with correct IDs to create a Pod.Object
table.insert (props, 1, "Spa:Pod:Object:Param:Props")
table.insert (props, 2, "Route")
-- convert arrays to Spa Pod
if props.channelVolumes then
table.insert (props.channelVolumes, 1, "Spa:Float")
props.channelVolumes = Pod.Array (props.channelVolumes)
end
if props.channelMap then
table.insert (props.channelMap, 1, "Spa:Enum:AudioChannel")
props.channelMap = Pod.Array (props.channelMap)
end
if props.iec958Codecs then
table.insert (props.iec958Codecs, 1, "Spa:Enum:AudioIEC958Codec")
props.iec958Codecs = Pod.Array (props.iec958Codecs)
end
-- construct Route param
local param = Pod.Object {
"Spa:Pod:Object:Param:Route", "Route",
index = route_info.index,
device = device_id,
props = Pod.Object (props),
save = route_info.save,
}
log:debug (param,
string.format ("setting route(%s) on for device(%s)(%s)",
route_info.name, dev_info.name, tostring (device)))
device:set_param ("Route", param)
::skip_route::
end
-- FIXME: add cancellability
-- sync on the pipewire connection to ensure that the params
-- have been configured on the remote device object
Core.sync (function ()
transition:advance ()
end)
end
},
}
}:register()

View File

@@ -0,0 +1,472 @@
-- WirePlumber
--
-- Copyright © 2021 Asymptotic Inc.
-- @author Sanchayan Maity <sanchayan@asymptotic.io>
--
-- 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()

View File

@@ -0,0 +1,75 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Find the best profile for a device based on profile priorities and
-- availability
cutils = require ("common-utils")
log = Log.open_topic ("s-device")
SimpleEventHook {
name = "device/find-best-profile",
after = "device/find-preferred-profile",
before = "device/apply-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-profile" },
},
},
execute = function (event)
local selected_profile = event:get_data ("selected-profile")
-- skip hook if profile is already selected
if selected_profile then
return
end
local device = event:get_subject ()
local dev_name = device.properties["device.name"] or ""
local off_profile = nil
local best_profile = nil
local unk_profile = nil
-- Takes absolute priority if available or unknown
local profile_prop = device.properties["device.profile"]
for p in device:iterate_params ("EnumProfile") do
profile = cutils.parseParam (p, "EnumProfile")
if profile and profile.name == profile_prop and profile.available ~= "no" then
selected_profile = profile
goto profile_set
elseif profile and profile.name ~= "pro-audio" then
if profile.name == "off" then
off_profile = profile
elseif profile.available == "yes" then
if best_profile == nil or profile.priority > best_profile.priority then
best_profile = profile
end
elseif profile.available ~= "no" then
if unk_profile == nil or profile.priority > unk_profile.priority then
unk_profile = profile
end
end
end
end
if best_profile ~= nil then
selected_profile = best_profile
elseif unk_profile ~= nil then
selected_profile = unk_profile
elseif off_profile ~= nil then
selected_profile = off_profile
end
::profile_set::
if selected_profile then
log:info (device, string.format (
"Found best profile '%s' (%d) for device '%s'",
selected_profile.name, selected_profile.index, dev_name))
event:set_data ("selected-profile", selected_profile)
end
end
}:register()

View File

@@ -0,0 +1,77 @@
-- WirePlumber
--
-- Copyright © 2021-2022 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- Based on default-routes.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
--
-- find the best route for a given device_id, based on availability and priority
cutils = require ("common-utils")
devinfo = require ("device-info-cache")
log = Log.open_topic ("s-device")
SimpleEventHook {
name = "device/find-best-routes",
after = "device/find-stored-routes",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-routes" },
Constraint { "profile.active-device-ids", "is-present" },
},
},
execute = function (event)
local device = event:get_subject ()
local event_properties = event:get_properties ()
local active_ids = event_properties ["profile.active-device-ids"]
local selected_routes = event:get_data ("selected-routes") or {}
local dev_info = devinfo:get_device_info (device)
assert (dev_info)
-- active IDs are exchanged in JSON format
active_ids = Json.Raw (active_ids):parse ()
for _, device_id in ipairs (active_ids) do
-- if a previous hook already selected a route for this device_id, skip it
if selected_routes [tostring (device_id)] then
goto next_device_id
end
local best_avail = nil
local best_unk = nil
for _, ri in pairs (dev_info.route_infos) do
if cutils.arrayContains (ri.devices, device_id) and
(ri.profiles == nil or cutils.arrayContains (ri.profiles, dev_info.active_profile)) then
if ri.available == "yes" or ri.available == "unknown" then
if ri.direction == "Output" and ri.available ~= ri.prev_available then
best_avail = ri
ri.save = true
break
elseif ri.available == "yes" then
if (best_avail == nil or ri.priority > best_avail.priority) then
best_avail = ri
end
elseif best_unk == nil or ri.priority > best_unk.priority then
best_unk = ri
end
end
end
end
local route = best_avail or best_unk
if route then
selected_routes [tostring (device_id)] =
Json.Object { index = route.index }:to_string ()
end
::next_device_id::
end
-- save the selected routes for the apply-routes hook
event:set_data ("selected-routes", selected_routes)
end
}:register ()

View File

@@ -0,0 +1,68 @@
-- WirePlumber
--
-- Copyright © 2024 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Finds the user preferred profile for a device, based on the priorities
-- defined in the "device.profile.priority.rules" section of the configuration.
cutils = require ("common-utils")
log = Log.open_topic ("s-device")
config = {}
config.rules = Conf.get_section_as_json ("device.profile.priority.rules", Json.Array {})
SimpleEventHook {
name = "device/find-preferred-profile",
after = "device/find-stored-profile",
before = "device/find-best-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-profile" },
},
},
execute = function (event)
local selected_profile = event:get_data ("selected-profile")
-- skip hook if the profile is already selected for this device.
if selected_profile then
return
end
local device = event:get_subject ()
local props = JsonUtils.match_rules_update_properties (
config.rules, device.properties)
local p_array = props["priorities"]
-- skip hook if the profile priorities are NOT defined for this device.
if not p_array then
return nil
end
local p_json = Json.Raw(p_array)
local priorities = p_json:parse()
for _, priority_profile in ipairs(priorities) do
for p in device:iterate_params("EnumProfile") do
local device_profile = cutils.parseParam(p, "EnumProfile")
if device_profile and device_profile.name == priority_profile then
selected_profile = device_profile
goto profile_set
end
end
end
::profile_set::
if selected_profile then
log:info (device, string.format (
"Found preferred profile '%s' (%d) for device '%s'",
selected_profile.name, selected_profile.index, device_name))
event:set_data ("selected-profile", selected_profile)
else
log:info (device, "Profiles listed in 'device.profile.priority.rules'"
.. " do not match the available ones of device: " .. device_name)
end
end
}:register()

View File

@@ -0,0 +1,28 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
-- look for new devices and raise select-profile event.
cutils = require ("common-utils")
log = Log.open_topic ("s-device")
SimpleEventHook {
name = "device/select-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-added" },
},
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "=", "EnumProfile" },
},
},
execute = function (event)
local source = event:get_source ()
local device = event:get_subject ()
source:call ("push-event", "select-profile", device, nil)
end
}:register()

View File

@@ -0,0 +1,160 @@
-- WirePlumber
--
-- Copyright © 2021-2022 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- Based on default-routes.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
--
-- Update the device info cache with the latest information from EnumRoute(all
-- the device routes) and trigger a "select-routes" event to select new routes
-- for the given device configuration, if it has changed
cutils = require ("common-utils")
devinfo = require ("device-info-cache")
log = Log.open_topic ("s-device")
SimpleEventHook {
name = "device/select-route",
after = "device/select-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-added" },
},
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "c", "EnumRoute" },
},
},
execute = function (event)
local source = event:get_source ()
local device = event:get_subject ()
local dev_info = devinfo:get_device_info (device)
if not dev_info then
return
end
local new_route_infos = {}
local avail_routes_changed = false
local profile = nil
-- get current profile
for p in device:iterate_params ("Profile") do
profile = cutils.parseParam (p, "Profile")
end
-- look at all the routes and update/reset cached information
for p in device:iterate_params ("EnumRoute") do
-- parse pod
local route = cutils.parseParam (p, "EnumRoute")
if not route then
goto skip_enum_route
end
-- find cached route information
local route_info = devinfo.find_route_info (dev_info, route, true)
-- update properties
route_info.prev_available = route_info.available
if route_info.available ~= route.available then
log:info (device, "route " .. route.name .. " available changed " ..
route_info.available .. " -> " .. route.available)
route_info.available = route.available
if profile and cutils.arrayContains (route.profiles, profile.index) then
avail_routes_changed = true
end
end
-- store
new_route_infos [route.index] = route_info
::skip_enum_route::
end
-- replace old route_infos to lose old routes
-- that no longer exist on the device
dev_info.route_infos = new_route_infos
new_route_infos = nil
-- restore routes for profile
if profile then
local profile_changed = (dev_info.active_profile ~= profile.index)
dev_info.active_profile = profile.index
-- if the profile changed, restore routes for that profile
-- if any of the routes of the current profile changed in availability,
-- then try to select a new "best" route for each device and ignore
-- what was stored
if profile_changed or avail_routes_changed then
log:info (device,
string.format ("restore routes for profile(%s) of device(%s)",
profile.name, dev_info.name))
-- find the active device IDs for which to select routes
local active_ids = findActiveDeviceIDs (profile)
active_ids = Json.Array (active_ids):to_string ()
-- push select-routes event and let the hooks select the appropriate routes
local props = {
["profile.changed"] = profile_changed,
["profile.name"] = profile.name,
["profile.active-device-ids"] = active_ids,
}
source:call ("push-event", "select-routes", device, props)
end
end
end
}:register()
-- These device ids are like routes(speaker, mic, headset etc) or sub-devices or
-- paths with in the pipewire devices/soundcards.
function findActiveDeviceIDs (profile)
-- parses the classes from the profile and returns the device IDs
----- sample structure, should return { 0, 8 } -----
-- classes:
-- 1: 2
-- 2:
-- 1: Audio/Source
-- 2: 1
-- 3: card.profile.devices
-- 4:
-- 1: 0
-- pod_type: Array
-- value_type: Spa:Int
-- pod_type: Struct
-- 3:
-- 1: Audio/Sink
-- 2: 1
-- 3: card.profile.devices
-- 4:
-- 1: 8
-- pod_type: Array
-- value_type: Spa:Int
-- pod_type: Struct
-- pod_type: Struct
local active_ids = {}
if type (profile.classes) == "table" and profile.classes.pod_type == "Struct" then
for _, p in ipairs (profile.classes) do
if type (p) == "table" and p.pod_type == "Struct" then
local i = 1
while true do
local k, v = p [i], p [i+1]
i = i + 2
if not k or not v then
break
end
if k == "card.profile.devices" and
type (v) == "table" and v.pod_type == "Array" then
for _, dev_id in ipairs (v) do
table.insert (active_ids, dev_id)
end
end
end
end
end
end
return active_ids
end

View File

@@ -0,0 +1,145 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- This file contains all the logic related to saving device profiles
-- to a state file and restoring them later on.
-- A devices profile needs to be selected for any new device. the script selects
-- the device profile from the user preferences, as well as store the user
-- selected device profile to state file
cutils = require ("common-utils")
log = Log.open_topic ("s-device")
-- the state storage
state = nil
state_table = nil
find_stored_profile_hook = SimpleEventHook {
name = "device/find-stored-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-profile" },
},
},
execute = function (event)
local selected_profile = event:get_data ("selected-profile")
-- skip hook if profile is already selected
if selected_profile then
return
end
local device = event:get_subject ()
local dev_name = device.properties["device.name"]
if not dev_name then
log:critical (device, "invalid device.name")
return
end
local profile_name = state_table[dev_name]
if profile_name then
for p in device:iterate_params ("EnumProfile") do
local profile = cutils.parseParam (p, "EnumProfile")
if profile.name == profile_name and profile.available ~= "no" then
selected_profile = profile
break
end
end
end
if selected_profile then
log:info (device, string.format (
"Found stored profile '%s' (%d) for device '%s'",
selected_profile.name, selected_profile.index, dev_name))
event:set_data ("selected-profile", selected_profile)
end
end
}
store_user_selected_profile_hook = SimpleEventHook {
name = "device/store-user-selected-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "=", "Profile" },
},
},
execute = function (event)
local device = event:get_subject ()
for p in device:iterate_params ("Profile") do
local profile = cutils.parseParam (p, "Profile")
if profile.save then
-- store only if this was a user-generated action (save == true)
updateStoredProfile (device, profile)
end
end
end
}
function updateStoredProfile (device, profile)
local dev_name = device.properties["device.name"]
local index = nil
if not dev_name then
log:critical (device, "invalid device.name")
return
end
log:debug (device, string.format (
"update stored profile to '%s' (%d) for device '%s'",
profile.name, profile.index, dev_name))
-- check if the new profile is the same as the current one
if state_table[dev_name] == profile.name then
log:debug (device, " ... profile is already stored")
return
end
-- find the full profile from EnumProfile, making also sure that the
-- user / client application has actually set an existing profile
for p in device:iterate_params ("EnumProfile") do
local enum_profile = cutils.parseParam (p, "EnumProfile")
if enum_profile.name == profile.name then
index = enum_profile.index
end
end
if not index then
log:info (device, string.format (
"profile '%s' (%d) is not valid on device '%s'",
profile.name, profile.index, dev_name))
return
end
state_table[dev_name] = profile.name
state:save_after_timeout (state_table)
log:info (device, string.format (
"stored profile '%s' (%d) for device '%s'",
profile.name, index, dev_name))
end
function toggleState (enable)
if enable and not state then
state = State ("default-profile")
state_table = state:load ()
find_stored_profile_hook:register ()
store_user_selected_profile_hook:register ()
elseif not enable and state then
state = nil
state_table = nil
find_stored_profile_hook:remove ()
store_user_selected_profile_hook:remove ()
end
end
Settings.subscribe ("device.restore-profile", function ()
toggleState (Settings.get_boolean ("device.restore-profile"))
end)
toggleState (Settings.get_boolean ("device.restore-profile"))

View File

@@ -0,0 +1,345 @@
-- WirePlumber
--
-- Copyright © 2021-2022 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- Based on default-routes.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
--
-- This file contains all the logic related to saving device routes and their
-- properties to a state file and restoring both the routes selection and
-- the properties of routes later on.
--
cutils = require ("common-utils")
devinfo = require ("device-info-cache")
log = Log.open_topic ("s-device")
-- the state storage
state = nil
state_table = nil
-- hook to restore routes selection for a newly selected profile
find_stored_routes_hook = SimpleEventHook {
name = "device/find-stored-routes",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-routes" },
Constraint { "profile.changed", "=", "true" },
Constraint { "profile.active-device-ids", "is-present" },
},
},
execute = function (event)
local device = event:get_subject ()
local event_properties = event:get_properties ()
local profile_name = event_properties ["profile.name"]
local active_ids = event_properties ["profile.active-device-ids"]
local selected_routes = event:get_data ("selected-routes") or {}
local dev_info = devinfo:get_device_info (device)
assert (dev_info)
-- get the stored routes for this profile
-- skip the hook if there are no stored routes, there is no point
local spr = getStoredProfileRoutes (dev_info, profile_name)
if #spr == 0 then
return
end
-- active IDs are exchanged in JSON format
active_ids = Json.Raw (active_ids):parse ()
for _, device_id in ipairs (active_ids) do
-- if a previous hook already selected a route for this device_id, skip it
if selected_routes [tostring (device_id)] then
goto next_device_id
end
log:info (device, "restoring route for device ID " .. tostring (device_id));
local route_info = nil
-- find a route that was previously stored for a device_id
for _, ri in pairs (dev_info.route_infos) do
if cutils.arrayContains (ri.devices, tonumber (device_id)) and
(ri.profiles == nil or cutils.arrayContains (ri.profiles, dev_info.active_profile)) and
cutils.arrayContains (spr, ri.name) then
route_info = ri
break
end
end
if route_info then
-- we found a stored route
if route_info.available == "no" then
log:info (device, "stored route '" .. route_info.name .. "' not available")
-- not available, try to find next best
route_info = nil
else
log:info (device, "found stored route: " .. route_info.name)
-- make sure we save it again
route_info.save = true
end
end
if route_info then
selected_routes [tostring (device_id)] =
Json.Object { index = route_info.index }:to_string ()
end
::next_device_id::
end
-- save the selected routes for the apply-routes hook
event:set_data ("selected-routes", selected_routes)
end
}
-- extract the "selected-routes" event data and augment it to include
-- the route properties, as they were stored in the state file;
-- this is the last step before applying the routes
apply_route_props_hook = SimpleEventHook {
name = "device/apply-route-props",
after = { "device/find-stored-routes", "device/find-best-routes" },
interests = {
EventInterest {
Constraint { "event.type", "=", "select-routes" },
},
},
execute = function (event)
local device = event:get_subject ()
local selected_routes = event:get_data ("selected-routes") or {}
local new_selected_routes = {}
local dev_info = devinfo:get_device_info (device)
assert (dev_info)
if next (selected_routes) == nil then
log:info (device, "No routes selected to set on " .. dev_info.name)
return
end
for device_id, route in pairs (selected_routes) do
-- JSON to lua table
route = Json.Raw (route):parse ()
local route_info = devinfo.find_route_info (dev_info, route, false)
local props = getStoredRouteProps (dev_info, route_info)
-- convert arrays to Json
if props.channelVolumes then
props.channelVolumes = Json.Array (props.channelVolumes)
end
if props.channelMap then
props.channelMap = Json.Array (props.channelMap)
end
if props.iec958Codecs then
props.iec958Codecs = Json.Array (props.iec958Codecs)
end
local json = Json.Object {
index = route_info.index,
props = Json.Object (props),
}
new_selected_routes [device_id] = json:to_string ()
end
-- save the selected routes for the apply-routes hook
event:set_data ("selected-routes", new_selected_routes)
end
}
store_or_restore_routes_hook = SimpleEventHook {
name = "device/store-or-restore-routes",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "=", "Route" },
},
},
execute = function (event)
local device = event:get_subject ()
local source = event:get_source ()
local selected_routes = {}
local push_select_routes = false
local dev_info = devinfo:get_device_info (device)
if not dev_info then
return
end
local new_route_infos = {}
-- look at all the routes and update/reset cached information
for p in device:iterate_params ("EnumRoute") do
-- parse pod
local route = cutils.parseParam (p, "EnumRoute")
if not route then
goto skip_enum_route
end
-- find cached route information
local route_info = devinfo.find_route_info (dev_info, route, true)
if not route_info then
goto skip_enum_route
end
-- update properties
route_info.prev_active = route_info.active
route_info.active = false
route_info.save = false
-- store
new_route_infos [route.index] = route_info
::skip_enum_route::
end
-- update route_infos with new prev_active, active and save changes
dev_info.route_infos = new_route_infos
new_route_infos = nil
-- check for changes in the active routes
for p in device:iterate_params ("Route") do
local route = cutils.parseParam (p, "Route")
if not route then
goto skip_route
end
-- get cached route info and at the same time
-- ensure that the route is also in EnumRoute
local route_info = devinfo.find_route_info (dev_info, route, false)
if not route_info then
goto skip_route
end
-- update route_info state
route_info.active = true
route_info.save = route.save
if not route_info.prev_active then
-- a new route is now active, restore the volume and
-- make sure we save this as a preferred route
log:info (device,
string.format ("new active route(%s) found of device(%s)",
route.name, dev_info.name))
route_info.prev_active = true
route_info.active = true
selected_routes [tostring (route.device)] =
Json.Object { index = route_info.index }:to_string ()
push_select_routes = true
elseif route.save and route.props then
-- just save route properties
log:info (device,
string.format ("storing route(%s) props of device(%s)",
route.name, dev_info.name))
saveRouteProps (dev_info, route)
end
::skip_route::
end
-- save selected routes for the active profile
for p in device:iterate_params ("Profile") do
local profile = cutils.parseParam (p, "Profile")
saveProfileRoutes (dev_info, profile.name)
end
-- push a select-routes event to re-apply the routes with new properties
if push_select_routes then
local e = source:call ("create-event", "select-routes", device, nil)
e:set_data ("selected-routes", selected_routes)
EventDispatcher.push_event (e)
end
end
}
function saveRouteProps (dev_info, route)
local props = route.props.properties
local key = dev_info.name .. ":" ..
route.direction:lower () .. ":" ..
route.name
state_table [key] = Json.Object {
volume = props.volume,
mute = props.mute,
channelVolumes = props.channelVolumes and Json.Array (props.channelVolumes),
channelMap = props.channelMap and Json.Array (props.channelMap),
latencyOffsetNsec = props.latencyOffsetNsec,
iec958Codecs = props.iec958Codecs and Json.Array (props.iec958Codecs),
}:to_string ()
state:save_after_timeout (state_table)
end
function getStoredRouteProps (dev_info, route)
local key = dev_info.name .. ":" ..
route.direction:lower () .. ":" ..
route.name
local value = state_table [key]
if value then
local json = Json.Raw (value)
if json and json:is_object () then
return json:parse ()
end
end
return {}
end
-- stores an array with the route names that are selected
-- for the given device and profile
function saveProfileRoutes (dev_info, profile_name)
-- select only routes with save == true
local routes = {}
for idx, ri in pairs (dev_info.route_infos) do
if ri.save then
table.insert (routes, ri.name)
end
end
if #routes > 0 then
local key = dev_info.name .. ":profile:" .. profile_name
state_table [key] = Json.Array (routes):to_string()
state:save_after_timeout (state_table)
end
end
-- returns an array of the route names that were previously selected
-- for the given device and profile
function getStoredProfileRoutes (dev_info, profile_name)
local key = dev_info.name .. ":profile:" .. profile_name
local value = state_table [key]
if value then
local json = Json.Raw (value)
if json and json:is_array () then
return json:parse ()
end
end
return {}
end
function toggleState (enable)
if enable and not state then
state = State ("default-routes")
state_table = state:load ()
find_stored_routes_hook:register ()
apply_route_props_hook:register ()
store_or_restore_routes_hook:register ()
elseif not enable and state then
state = nil
state_table = nil
find_stored_routes_hook:remove ()
apply_route_props_hook:remove ()
store_or_restore_routes_hook:remove ()
end
end
Settings.subscribe ("device.restore-routes", function ()
toggleState (Settings.get_boolean ("device.restore-routes"))
end)
toggleState (Settings.get_boolean ("device.restore-routes"))