Moving files:

This commit is contained in:
Antoine Phan
2024-07-27 00:06:16 +07:00
parent 9716a8df5a
commit 0cd53e0b65
260 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic ("s-client")
config = {}
config.rules = Conf.get_section_as_json ("access.rules")
function getAccess (properties)
local access = properties["pipewire.access"]
local client_access = properties["pipewire.client.access"]
local is_flatpak = properties["pipewire.sec.flatpak"]
if is_flatpak then
client_access = "flatpak"
end
if client_access == nil then
return access
elseif access == "unrestricted" or access == "default" then
if client_access ~= "unrestricted" then
return client_access
end
end
return access
end
function getDefaultPermissions (properties)
local access = properties["access"]
local media_category = properties["media.category"]
if access == "flatpak" and media_category == "Manager" then
return "all", "flatpak-manager"
elseif access == "flatpak" or access == "restricted" then
return "rx", access
elseif access == "default" then
return "all", access
end
return nil, nil
end
function getPermissions (properties)
if config.rules then
local mprops, matched = JsonUtils.match_rules_update_properties (
config.rules, properties)
if (matched > 0 and mprops["default_permissions"]) then
return mprops["default_permissions"], mprops["access"]
end
end
return nil, nil
end
clients_om = ObjectManager {
Interest { type = "client" }
}
clients_om:connect("object-added", function (om, client)
local id = client["bound-id"]
local properties = client["properties"]
local access = getAccess (properties)
properties["access"] = access
local perms, effective_access = getPermissions (properties)
if perms == nil then
perms, effective_access = getDefaultPermissions (properties)
end
if effective_access == nil then
effective_access = access
end
if perms ~= nil then
log:info(client, "Granting permissions to client " .. id .. " (access " ..
effective_access .. "): " .. perms)
client:update_permissions { ["any"] = perms }
client:update_properties { ["pipewire.access.effective"] = effective_access }
else
log:debug(client, "No rule for client " .. id .. " (access " .. access .. ")")
end
end)
clients_om:activate()

View File

@@ -0,0 +1,143 @@
MEDIA_ROLE_NONE = 0
MEDIA_ROLE_CAMERA = 1 << 0
log = Log.open_topic ("s-client")
function hasPermission (permissions, app_id, lookup)
if permissions then
for key, values in pairs(permissions) do
if key == app_id then
for _, v in pairs(values) do
if v == lookup then
return true
end
end
end
end
end
return false
end
function parseMediaRoles (media_roles_str)
local media_roles = MEDIA_ROLE_NONE
for role in media_roles_str:gmatch('[^,%s]+') do
if role == "Camera" then
media_roles = media_roles | MEDIA_ROLE_CAMERA
end
end
return media_roles
end
function setPermissions (client, allow_client, allow_nodes)
local client_id = client["bound-id"]
log:info(client, "Granting ALL access to client " .. client_id)
-- Update permissions on client
client:update_permissions { [client_id] = allow_client and "all" or "-" }
-- Update permissions on camera source nodes
for node in nodes_om:iterate() do
local node_id = node["bound-id"]
client:update_permissions { [node_id] = allow_nodes and "all" or "-" }
end
end
function updateClientPermissions (client, permissions)
local client_id = client["bound-id"]
local str_prop = nil
local app_id = nil
local media_roles = nil
local allowed = false
-- Make sure the client is not the portal itself
str_prop = client.properties["pipewire.access.portal.is_portal"]
if str_prop == "yes" then
log:info (client, "client is the portal itself")
return
end
-- Make sure the client has a portal app Id
str_prop = client.properties["pipewire.access.portal.app_id"]
if str_prop == nil then
log:info (client, "Portal managed client did not set app_id")
return
end
if str_prop == "" then
log:info (client, "Ignoring portal check for non-sandboxed client")
setPermissions (client, true, true)
return
end
app_id = str_prop
-- Make sure the client has portal media roles
str_prop = client.properties["pipewire.access.portal.media_roles"]
if str_prop == nil then
log:info (client, "Portal managed client did not set media_roles")
return
end
media_roles = parseMediaRoles (str_prop)
if (media_roles & MEDIA_ROLE_CAMERA) == 0 then
log:info (client, "Ignoring portal check for clients without camera role")
return
end
-- Update permissions
allowed = hasPermission (permissions, app_id, "yes")
log:info (client, "setting permissions: " .. tostring(allowed))
setPermissions (client, allowed, allowed)
end
-- Create portal clients object manager
clients_om = ObjectManager {
Interest {
type = "client",
Constraint { "pipewire.access", "=", "portal" },
}
}
-- Set permissions to portal clients from the permission store if loaded
pps_plugin = Plugin.find("portal-permissionstore")
if pps_plugin then
nodes_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.role", "=", "Camera" },
Constraint { "media.class", "=", "Video/Source" },
}
}
nodes_om:activate()
clients_om:connect("object-added", function (om, client)
local new_perms = pps_plugin:call("lookup", "devices", "camera");
updateClientPermissions (client, new_perms)
end)
nodes_om:connect("object-added", function (om, node)
local new_perms = pps_plugin:call("lookup", "devices", "camera");
for client in clients_om:iterate() do
updateClientPermissions (client, new_perms)
end
end)
pps_plugin:connect("changed", function (p, table, id, deleted, permissions)
if table == "devices" or id == "camera" then
for app_id, _ in pairs(permissions) do
for client in clients_om:iterate {
Constraint { "pipewire.access.portal.app_id", "=", app_id }
} do
updateClientPermissions (client, permissions)
end
end
end
end)
else
-- Otherwise, just set all permissions to all portal clients
clients_om:connect("object-added", function (om, client)
local id = client["bound-id"]
log:info(client, "Granting ALL access to client " .. id)
client:update_permissions { ["any"] = "all" }
end)
end
clients_om:activate()

View File

@@ -0,0 +1,87 @@
-- Manage snap audio permissions
--
-- Copyright © 2023 Canonical Ltd.
-- @author Sergio Costas Rodriguez <sergio.costas@canonical.com>
--
-- SPDX-License-Identifier: MIT
function removeClientPermissionsForOtherClients (client)
-- Remove access to any other clients, but allow all the process of the
-- same snap to access their elements
local client_id = client.properties["pipewire.snap.id"]
for snap_client in clients_snap:iterate() do
local snap_client_id = snap_client.properties["pipewire.snap.id"]
if snap_client_id ~= client_id then
client:update_permissions { [snap_client["bound-id"]] = "-" }
end
end
for no_snap_client in clients_no_snap:iterate() do
client:update_permissions { [no_snap_client["bound-id"]] = "-" }
end
end
function updateClientPermissions (client)
-- Remove access to Audio/Sources and Audio/Sinks based on snap permissions
for node in nodes_om:iterate() do
local node_id = node["bound-id"]
local property = "pipewire.snap.audio.playback"
if node.properties["media.class"] == "Audio/Source" then
property = "pipewire.snap.audio.record"
end
if client.properties[property] ~= "true" then
client:update_permissions { [node_id] = "-" }
end
end
end
clients_snap = ObjectManager {
Interest {
type = "client",
Constraint { "pipewire.snap.id", "+", type = "pw"},
}
}
clients_no_snap = ObjectManager {
Interest {
type = "client",
Constraint { "pipewire.snap.id", "-", type = "pw"},
}
}
nodes_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.class", "matches", "Audio/*"}
}
}
clients_snap:connect("object-added", function (om, client)
-- If a new snap client is added, adjust its permissions
updateClientPermissions (client)
removeClientPermissionsForOtherClients (client)
end)
clients_no_snap:connect("object-added", function (om, client)
-- If a new, non-snap client is added,
-- remove access to it from other snaps
client_id = client["bound-id"]
for snap_client in clients_snap:iterate() do
if client.properties["pipewire.snap.id"] ~= nil then
snap_client:update_permissions { [client_id] = "-" }
end
end
end)
nodes_om:connect("object-added", function (om, node)
-- If a new Audio/Sink or Audio/Source node is added,
-- adjust the permissions in the snap clients
for client in clients_snap:iterate() do
updateClientPermissions (client)
end
end)
clients_snap:activate()
clients_no_snap:activate()
nodes_om:activate()

View File

@@ -0,0 +1,39 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic ("s-default-nodes")
SimpleEventHook {
name = "default-nodes/apply-default-node",
after = { "default-nodes/find-best-default-node",
"default-nodes/find-selected-default-node",
"default-nodes/find-stored-default-node" },
interests = {
EventInterest {
Constraint { "event.type", "=", "select-default-node" },
},
},
execute = function (event)
local source = event:get_source ()
local props = event:get_properties ()
local def_node_type = props ["default-node.type"]
local selected_node = event:get_data ("selected-node")
local om = source:call ("get-object-manager", "metadata")
local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } }
if selected_node then
local key = "default." .. def_node_type
log:info ("set default node for " .. key .. " " .. selected_node)
metadata:set (0, key, "Spa:String:JSON",
Json.Object { ["name"] = selected_node }:to_string ())
else
metadata:set (0, "default." .. def_node_type, nil, nil)
end
end
}:register ()

View File

@@ -0,0 +1,41 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic ("s-default-nodes")
nutils = require ("node-utils")
SimpleEventHook {
name = "default-nodes/find-best-default-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-default-node" },
},
},
execute = function (event)
local available_nodes = event:get_data ("available-nodes")
local selected_prio = event:get_data ("selected-node-priority") or 0
local selected_node = event:get_data ("selected-node")
available_nodes = available_nodes and available_nodes:parse ()
if not available_nodes then
return
end
for _, node_props in ipairs (available_nodes) do
-- Highest priority node wins
local priority = nutils.get_session_priority (node_props)
if priority > selected_prio or selected_node == nil then
selected_prio = priority
selected_node = node_props ["node.name"]
end
end
event:set_data ("selected-node-priority", selected_prio)
event:set_data ("selected-node", selected_node)
end
}:register ()

View File

@@ -0,0 +1,70 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
-- hook to make sure the user prefered device(default.configured.*) in other
-- words currently selected device is given higher priority
-- state-default-nodes.lua also does find out the default node out of the user
-- preferences(current and past), however it doesnt give any higher priority to
-- the currently selected device.
log = Log.open_topic ("s-default-nodes")
nutils = require ("node-utils")
SimpleEventHook {
name = "default-nodes/find-selected-default-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-default-node" },
},
},
execute = function (event)
local available_nodes = event:get_data ("available-nodes")
available_nodes = available_nodes and available_nodes:parse ()
if not available_nodes then
return
end
local selected_prio = event:get_data ("selected-node-priority") or 0
local selected_node = event:get_data ("selected-node")
local source = event:get_source ()
local props = event:get_properties ()
local def_node_type = props ["default-node.type"]
local metadata_om = source:call ("get-object-manager", "metadata")
local metadata = metadata_om:lookup { Constraint { "metadata.name", "=", "default" } }
local obj = metadata:find (0, "default.configured." .. def_node_type)
if not obj then
return
end
local json = Json.Raw (obj)
local current_configured_node = json:parse ().name
for _, node_props in ipairs (available_nodes) do
local name = node_props ["node.name"]
local priority = nutils.get_session_priority (node_props)
if current_configured_node == name then
priority = 30000 + priority
if priority > selected_prio then
selected_prio = priority
selected_node = name
event:set_data ("selected-node-priority", selected_prio)
event:set_data ("selected-node", selected_node)
end
break
end
end
end
}:register ()

View File

@@ -0,0 +1,179 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
-- looks for changes in user-preferences and devices added/removed and schedules
-- rescan and pushes "select-default-node" event for each of the media_classes
log = Log.open_topic ("s-default-nodes")
-- looks for changes in user-preferences and devices added/removed and schedules
-- rescan
SimpleEventHook {
name = "default-nodes/rescan-trigger",
interests = {
EventInterest {
Constraint { "event.type", "c", "session-item-added", "session-item-removed" },
Constraint { "event.session-item.interface", "=", "linkable" },
Constraint { "media.class", "#", "Audio/*" },
},
EventInterest {
Constraint { "event.type", "c", "session-item-added", "session-item-removed" },
Constraint { "event.session-item.interface", "=", "linkable" },
Constraint { "media.class", "#", "Video/*" },
},
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "c", "default.configured.audio.sink",
"default.configured.audio.source", "default.configured.video.source"
},
},
EventInterest {
Constraint { "event.type", "=", "device-params-changed"},
Constraint { "event.subject.param-id", "c", "Route", "EnumRoute"},
},
},
execute = function (event)
local source = event:get_source ()
source:call ("schedule-rescan", "default-nodes")
end
}:register ()
-- pushes "select-default-node" event for each of the media_classes
SimpleEventHook {
name = "default-nodes/rescan",
interests = {
EventInterest {
Constraint { "event.type", "=", "rescan-for-default-nodes" },
},
},
execute = function (event)
local source = event:get_source ()
local si_om = source:call ("get-object-manager", "session-item")
local devices_om = source:call ("get-object-manager", "device")
log:trace ("re-evaluating default nodes")
-- Audio Sink
pushSelectDefaultNodeEvent (source, si_om, devices_om, "audio.sink", "in", {
"Audio/Sink", "Audio/Duplex"
})
-- Audio Source
pushSelectDefaultNodeEvent (source, si_om, devices_om, "audio.source", "out", {
"Audio/Source", "Audio/Source/Virtual", "Audio/Duplex", "Audio/Sink"
})
-- Video Source
pushSelectDefaultNodeEvent (source, si_om, devices_om, "video.source", "out", {
"Video/Source", "Video/Source/Virtual"
})
end
}:register ()
function pushSelectDefaultNodeEvent (source, si_om, devices_om, def_node_type,
port_direction, media_classes)
local nodes =
collectAvailableNodes (si_om, devices_om, port_direction, media_classes)
local event = source:call ("create-event", "select-default-node", nil, {
["default-node.type"] = def_node_type,
})
event:set_data ("available-nodes", Json.Array (nodes))
EventDispatcher.push_event (event)
end
-- Return an array table where each element is another table containing all the
-- node properties of all the nodes that can be selected for a given media class
-- set and direction
function collectAvailableNodes (si_om, devices_om, port_direction, media_classes)
local collected = {}
for linkable in si_om:iterate {
type = "SiLinkable",
Constraint { "media.class", "c", table.unpack (media_classes) },
} do
local linkable_props = linkable.properties
local node = linkable:get_associated_proxy ("node")
-- check that the node has ports in the requested direction
if not node:lookup_port {
Constraint { "port.direction", "=", port_direction }
} then
goto next_linkable
end
-- check that the node has available routes,
-- if it is associated to a real device
if not nodeHasAvailableRoutes (node, devices_om) then
goto next_linkable
end
table.insert (collected, Json.Object (node.properties))
::next_linkable::
end
return collected
end
-- If the node has an associated device, verify that it has an available
-- route. Some UCM profiles expose all paths (headphones, HDMI, etc) as nodes,
-- even though they may not be connected... See #145
function nodeHasAvailableRoutes (node, devices_om)
local properties = node.properties
local device_id = properties ["device.id"]
local cpd = properties ["card.profile.device"]
if not device_id or not cpd then
return true
end
-- Get the device
local device = devices_om:lookup {
Constraint { "bound-id", "=", device_id, type = "gobject" }
}
if not device then
return true
end
-- Check if the current device route supports the node card device profile
for r in device:iterate_params ("Route") do
local route = r:parse ()
local route_props = route.properties
if route_props.device == tonumber (cpd) then
if route_props.available == "no" then
return false
else
return true
end
end
end
-- Check if available routes support the node card device profile
local found = 0
for r in device:iterate_params ("EnumRoute") do
local route = r:parse ()
local route_props = route.properties
if type (route_props.devices) == "table" then
for _, i in ipairs (route_props.devices) do
if i == tonumber (cpd) then
found = found + 1
if route_props.available ~= "no" then
return true
end
end
end
end
end
-- The node is part of a profile without routes so we assume it
-- is available. This can happen for Pro Audio profiles
if found == 0 then
return true
end
return false
end

View File

@@ -0,0 +1,199 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
-- the script states the default nodes from the user preferences, it has hooks
-- which stores the user preferences(it stores not just the current preference
-- but all the previous preferences) in to the state file, retrives them from
-- state file during the bootup, finally it has a hook which finds a default
-- node out of the user preferences
log = Log.open_topic ("s-default-nodes")
nutils = require ("node-utils")
-- the state storage
state = nil
state_table = nil
find_stored_default_node_hook = SimpleEventHook {
name = "default-nodes/find-stored-default-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-default-node" },
},
},
execute = function (event)
local props = event:get_properties ()
local available_nodes = event:get_data ("available-nodes")
local selected_prio = event:get_data ("selected-node-priority") or 0
local selected_node = event:get_data ("selected-node")
available_nodes = available_nodes and available_nodes:parse ()
if not available_nodes then
return
end
local stored = collectStored (props ["default-node.type"])
-- Check if any of the available nodes matches any of the configured
for _, node_props in ipairs (available_nodes) do
local name = node_props ["node.name"]
for i, v in ipairs (stored) do
if name == v then
local priority = nutils.get_session_priority (node_props)
priority = priority + 20001 - i
if priority > selected_prio then
selected_prio = priority
selected_node = name
end
break
end
end
end
if selected_node then
event:set_data ("selected-node-priority", selected_prio)
event:set_data ("selected-node", selected_node)
end
end
}
store_configured_default_nodes_hook = SimpleEventHook {
name = "default-nodes/store-configured-default-nodes",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "c", "default.configured.audio.sink",
"default.configured.audio.source", "default.configured.video.source"
},
},
},
execute = function (event)
local props = event:get_properties ()
-- get the part after "default.configured." (= 19 chars)
local def_node_type = props ["event.subject.key"]:sub (20)
local new_value = props ["event.subject.value"]
local new_stored = {}
if new_value then
new_value = Json.Raw (new_value):parse () ["name"]
end
if new_value then
local stored = collectStored (def_node_type)
local pos = #stored + 1
-- find if the curent configured value is already in the stack
for i, v in ipairs (stored) do
if v == new_value then
pos = i
break
end
end
-- insert at the top and shift the remaining to fill the gap
new_stored [1] = new_value
if pos > 1 then
table.move (stored, 1, pos-1, 2, new_stored)
end
if pos < #stored then
table.move (stored, pos+1, #stored, pos+1, new_stored)
end
end
updateStored (def_node_type, new_stored)
end
}
-- set initial values
metadata_added_hook = SimpleEventHook {
name = "default-nodes/metadata-added",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-added" },
Constraint { "metadata.name", "=", "default" },
},
},
execute = function (event)
local types = { "audio.sink", "audio.source", "video.source" }
local source = event:get_source ()
local om = source:call ("get-object-manager", "metadata")
local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } }
for _, t in ipairs (types) do
local v = state_table ["default.configured." .. t]
if v then
metadata:set (0, "default.configured." .. t, "Spa:String:JSON",
Json.Object { ["name"] = v }:to_string ())
end
end
end
}
-- Collect all the previously configured node names from the state file
function collectStored (def_node_type)
local stored = {}
local key_base = "default.configured." .. def_node_type
local key = key_base
local index = 0
repeat
local v = state_table [key]
table.insert (stored, v)
key = key_base .. "." .. tostring (index)
index = index + 1
until v == nil
return stored
end
-- Store the given node names in the state file
function updateStored (def_node_type, stored)
local key_base = "default.configured." .. def_node_type
local key = key_base
local index = 0
for _, v in ipairs (stored) do
state_table [key] = v
key = key_base .. "." .. tostring (index)
index = index + 1
end
-- erase the rest, if any
repeat
local v = state_table [key]
state_table [key] = nil
key = key_base .. "." .. tostring (index)
index = index + 1
until v == nil
state:save_after_timeout (state_table)
end
function toggleState (enable)
if enable and not state then
state = State ("default-nodes")
state_table = state:load ()
find_stored_default_node_hook:register ()
store_configured_default_nodes_hook:register ()
metadata_added_hook:register ()
elseif not enable and state then
state = nil
state_table = nil
find_stored_default_node_hook:remove ()
store_configured_default_nodes_hook:remove ()
metadata_added_hook:remove ()
end
end
Settings.subscribe ("node.restore-default-targets", function ()
toggleState (Settings.get_boolean ("node.restore-default-targets"))
end)
toggleState (Settings.get_boolean ("node.restore-default-targets"))

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"))

View File

@@ -0,0 +1,93 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Frédéric Danis <frederic.danis@collabora.com>
--
-- SPDX-License-Identifier: MIT
sink_ids = {}
fallback_node = nil
node_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.class", "matches", "Audio/Sink", type = "pw-global" },
-- Do not consider virtual items created by WirePlumber
Constraint { "wireplumber.is-virtual", "!", true, type = "pw" },
-- or the fallback sink itself
Constraint { "wireplumber.is-fallback", "!", true, type = "pw" },
}
}
function createFallbackSink()
if fallback_node then
return
end
Log.info("Create fallback sink")
local properties = {}
properties["node.name"] = "auto_null"
properties["node.description"] = "Dummy Output"
properties["audio.rate"] = 48000
properties["audio.channels"] = 2
properties["audio.position"] = "FL,FR"
properties["media.class"] = "Audio/Sink"
properties["factory.name"] = "support.null-audio-sink"
properties["node.virtual"] = "true"
properties["monitor.channel-volumes"] = "true"
properties["wireplumber.is-fallback"] = "true"
properties["priority.session"] = 500
fallback_node = LocalNode("adapter", properties)
fallback_node:activate(Feature.Proxy.BOUND)
end
function checkSinks()
local sink_ids_items = 0
for _ in pairs(sink_ids) do sink_ids_items = sink_ids_items + 1 end
if sink_ids_items > 0 then
if fallback_node then
Log.info("Remove fallback sink")
fallback_node = nil
end
elseif not fallback_node then
createFallbackSink()
end
end
function checkSinksAfterTimeout()
if timeout_source then
timeout_source:destroy()
end
timeout_source = Core.timeout_add(1000, function ()
checkSinks()
timeout_source = nil
end)
end
node_om:connect("object-added", function (_, node)
Log.debug("object added: " .. node.properties["object.id"] .. " " ..
tostring(node.properties["node.name"]))
sink_ids[node.properties["object.id"]] = node.properties["node.name"]
checkSinksAfterTimeout()
end)
node_om:connect("object-removed", function (_, node)
Log.debug("object removed: " .. node.properties["object.id"] .. " " ..
tostring(node.properties["node.name"]))
sink_ids[node.properties["object.id"]] = nil
checkSinksAfterTimeout()
end)
node_om:activate()
checkSinksAfterTimeout()

View File

@@ -0,0 +1,94 @@
-- WirePlumber
-- Copyright © 2022 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
-- SPDX-License-Identifier: MIT
-- Script is a Lua Module of common Lua utility functions
local cutils = {}
function cutils.parseBool (var)
return var and (var:lower () == "true" or var == "1")
end
function cutils.parseParam (param, id)
local props = param:parse ()
if props.pod_type == "Object" and props.object_id == id then
return props.properties
else
return nil
end
end
function cutils.mediaClassToDirection (media_class)
if media_class:find ("Sink") or
media_class:find ("Input") or
media_class:find ("Duplex") then
return "input"
elseif media_class:find ("Source") or media_class:find ("Output") then
return "output"
else
return nil
end
end
function cutils.getTargetDirection (properties)
local target_direction = nil
if properties ["item.node.direction"] == "output" or
(properties ["item.node.direction"] == "input" and
cutils.parseBool (properties ["stream.capture.sink"])) then
target_direction = "input"
else
target_direction = "output"
end
return target_direction
end
local default_nodes = Plugin.find ("default-nodes-api")
function cutils.getDefaultNode (properties, target_direction)
local target_media_class =
properties ["media.type"] ..
(target_direction == "input" and "/Sink" or "/Source")
if not default_nodes then
default_nodes = Plugin.find ("default-nodes-api")
end
return default_nodes:call ("get-default-node", target_media_class)
end
cutils.source_plugin = nil
cutils.object_managers = {}
function cutils.get_object_manager (name)
cutils.source_plugin = cutils.source_plugin or
Plugin.find ("standard-event-source")
cutils.object_managers [name] = cutils.object_managers [name] or
cutils.source_plugin:call ("get-object-manager", name)
return cutils.object_managers [name]
end
function cutils.get_default_metadata_object ()
return cutils.get_object_manager ("metadata"):lookup {
Constraint { "metadata.name", "=", "default" },
}
end
function cutils.arrayContains (a, value)
for _, v in ipairs (a) do
if v == value then
return true
end
end
return false
end
function cutils.get_application_name ()
return Core.get_properties()["application.name"] or "WirePlumber"
end
return cutils

View File

@@ -0,0 +1,74 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
local module = {
-- table of device info
dev_infos = {},
}
SimpleEventHook {
name = "lib/device-info-cache/cleanup",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-removed" },
},
},
execute = function (event)
local props = event:get_properties ()
local device_id = props ["object.serial"]
Log.trace ("cleaning up dev_info for object.serial = " .. device_id)
module.dev_infos [device_id] = nil
end
}:register()
function module.get_device_info (self, device)
local device_properties = device.properties
local device_id = device_properties ["object.serial"]
local dev_info = self.dev_infos [device_id]
-- new device
if not dev_info then
local device_name = device_properties ["device.name"]
if not device_name then
Log.critical (device, "invalid device.name")
return nil
end
Log.trace (device, string.format (
"create dev_info for '%s', object.serial = %s", device_name, device_id))
dev_info = {
name = device_name,
active_profile = -1,
route_infos = {},
}
self.dev_infos [device_id] = dev_info
end
return dev_info
end
function module.find_route_info (dev_info, route, return_new)
local ri = dev_info.route_infos [route.index]
if not ri and return_new then
ri = {
index = route.index,
name = route.name,
direction = route.direction,
devices = route.devices or {},
profiles = route.profiles,
priority = route.priority or 0,
available = route.available or "unknown",
prev_available = route.available or "unknown",
active = false,
prev_active = false,
save = false,
}
end
return ri
end
return module

View File

@@ -0,0 +1,501 @@
-- WirePlumber
-- Copyright © 2023 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
-- SPDX-License-Identifier: MIT
-- Script is a Lua Module of filter Lua utility functions
local cutils = require ("common-utils")
local module = {
metadata = nil,
filters = {},
}
local function getFilterSmart (metadata, node)
-- Check metadata
if metadata ~= nil then
local id = node["bound-id"]
local value_str = metadata:find (id, "filter.smart")
if value_str ~= nil then
local json = Json.Raw (value_str)
if json:is_boolean() then
return json:parse()
end
end
end
-- Check node properties
local prop_str = node.properties ["filter.smart"]
if prop_str ~= nil then
return cutils.parseBool (prop_str)
end
-- Otherwise consider the filter not smart by default
return false
end
local function getFilterSmartName (metadata, node)
-- Check metadata
if metadata ~= nil then
local id = node["bound-id"]
local value_str = metadata:find (id, "filter.smart.name")
if value_str ~= nil then
local json = Json.Raw (value_str)
if json:is_string() then
return json:parse()
end
end
end
-- Check node properties
local prop_str = node.properties ["filter.smart.name"]
if prop_str ~= nil then
return prop_str
end
-- Otherwise use link group as name
return node.properties ["node.link-group"]
end
local function getFilterSmartDisabled (metadata, node)
-- Check metadata
if metadata ~= nil then
local id = node["bound-id"]
local value_str = metadata:find (id, "filter.smart.disabled")
if value_str ~= nil then
local json = Json.Raw (value_str)
if json:is_boolean() then
return json:parse()
end
end
end
-- Check node properties
local prop_str = node.properties ["filter.smart.disabled"]
if prop_str ~= nil then
return cutils.parseBool (prop_str)
end
-- Otherwise consider the filter not disabled by default
return false
end
local function getFilterSmartTargetable (metadata, node)
-- Check metadata
if metadata ~= nil then
local id = node["bound-id"]
local value_str = metadata:find (id, "filter.smart.targetable")
if value_str ~= nil then
local json = Json.Raw (value_str)
if json:is_boolean() then
return json:parse()
end
end
end
-- Check node properties
local prop_str = node.properties ["filter.smart.targetable"]
if prop_str ~= nil then
return cutils.parseBool (prop_str)
end
-- Otherwise consider the filter not targetable by default
return false
end
local function getFilterSmartTarget (metadata, node, om)
-- Check metadata and fallback to properties
local id = node["bound-id"]
local value_str = nil
if metadata ~= nil then
value_str = metadata:find (id, "filter.smart.target")
end
if value_str == nil then
value_str = node.properties ["filter.smart.target"]
if value_str == nil then
return nil
end
end
-- Parse match rules
local match_rules_json = Json.Raw (value_str)
if not match_rules_json:is_object () then
return nil
end
local match_rules = match_rules_json:parse ()
-- Find target
local target = nil
for si_target in om:iterate { type = "SiLinkable" } do
local n_target = si_target:get_associated_proxy ("node")
if n_target == nil then
goto skip_target
end
-- Target nodes cannot be smart filters
if n_target.properties ["node.link-group"] ~= nil and
getFilterSmart (metadata, n_target) then
goto skip_target
end
-- Make sure the target node properties match all rules
for key, val in pairs(match_rules) do
if n_target.properties[key] ~= tostring (val) then
goto skip_target
end
end
-- Target found
target = si_target
break;
::skip_target::
end
return target
end
local function getFilterSmartTargetless (metadata, node)
local id = node["bound-id"]
local value_str = nil
if metadata ~= nil then
value_str = metadata:find (id, "filter.smart.target")
end
if value_str == nil then
value_str = node.properties ["filter.smart.target"]
end
return value_str == nil
end
local function getFilterSmartBefore (metadata, node)
-- Check metadata and fallback to properties
local id = node["bound-id"]
local value_str = nil
if metadata ~= nil then
value_str = metadata:find (id, "filter.smart.before")
end
if value_str == nil then
value_str = node.properties ["filter.smart.before"]
if value_str == nil then
return nil
end
end
-- Parse
local before_json = Json.Raw (value_str)
if not before_json:is_array() then
return nil
end
return before_json:parse ()
end
local function getFilterSmartAfter (metadata, node)
-- Check metadata and fallback to properties
local id = node["bound-id"]
local value_str = nil
if metadata ~= nil then
value_str = metadata:find (id, "filter.smart.after")
end
if value_str == nil then
value_str = node.properties ["filter.smart.after"]
if value_str == nil then
return nil
end
end
-- Parse
local after_json = Json.Raw (value_str)
if not after_json:is_array() then
return nil
end
return after_json:parse ()
end
local function insertFilterSorted (curr_filters, filter)
local before_filters = {}
local after_filters = {}
local new_filters = {}
-- Check if the current filters need to be inserted before or after
for i, v in ipairs(curr_filters) do
local insert_before = true
local insert_after = false
if v.before ~= nil then
for j, b in ipairs(v.before) do
if filter.name == b then
insert_after = false
break
end
end
end
if v.after ~= nil then
for j, b in ipairs(v.after) do
if filter.name == b then
insert_before = false
break
end
end
end
if filter.before ~= nil then
for j, b in ipairs(filter.before) do
if v.name == b then
insert_after = true
end
end
end
if filter.after ~= nil then
for j, b in ipairs(filter.after) do
if v.name == b then
insert_before = true
end
end
end
if insert_before then
if insert_after then
Log.warning ("cyclic before/after found in filters " .. v.name .. " and " .. filter.name)
end
table.insert (before_filters, v)
else
table.insert (after_filters, v)
end
end
-- Add the filters to the new table stored
for i, v in ipairs(before_filters) do
table.insert (new_filters, v)
end
table.insert (new_filters, filter)
for i, v in ipairs(after_filters) do
table.insert (new_filters, v)
end
return new_filters
end
local function rescanFilters (om, metadata_om)
local metadata =
metadata_om:lookup { Constraint { "metadata.name", "=", "filters" } }
-- Always clear all filters data on rescan
module.filters = {}
Log.info ("rescanning filters...")
for si in om:iterate { type = "SiLinkable" } do
local filter = {}
local n = si:get_associated_proxy ("node")
if n == nil then
goto skip_linkable
end
-- Only handle nodes with link group (filters)
filter.link_group = n.properties ["node.link-group"]
if filter.link_group == nil then
goto skip_linkable
end
-- Only handle the main filter nodes
filter.media_class = n.properties ["media.class"]
if string.find (filter.media_class, "Stream") then
goto skip_linkable
end
-- Filter direction
if string.find (filter.media_class, "Audio/Sink") or
string.find (filter.media_class, "Video/Sink") then
filter.direction = "input"
else
filter.direction = "output"
end
-- Filter media type
filter.media_type = si.properties["media.type"]
-- Get filter properties
filter.smart = getFilterSmart (metadata, n)
filter.name = getFilterSmartName (metadata, n)
filter.disabled = getFilterSmartDisabled (metadata, n)
filter.targetable = getFilterSmartTargetable (metadata, n)
filter.target = getFilterSmartTarget (metadata, n, om)
filter.targetless = getFilterSmartTargetless (metadata, n)
filter.before = getFilterSmartBefore (metadata, n)
filter.after = getFilterSmartAfter (metadata, n)
-- Add the main and stream session items
filter.main_si = si
filter.stream_si = om:lookup {
type = "SiLinkable",
Constraint { "node.link-group", "=", filter.link_group },
Constraint { "media.class", "#", "Stream/*", type = "pw-global" }
}
-- Add the filter to the list sorted by before and after
module.filters = insertFilterSorted (module.filters, filter)
::skip_linkable::
end
end
SimpleEventHook {
name = "lib/filter-utils/rescan",
before = "linking/rescan",
interests = {
EventInterest {
Constraint { "event.type", "=", "rescan-for-linking" },
},
},
execute = function (event)
local source = event:get_source ()
local om = source:call ("get-object-manager", "session-item")
local metadata_om = source:call ("get-object-manager", "metadata")
rescanFilters (om, metadata_om)
end
}:register ()
function module.is_filter_smart (direction, link_group)
-- Make sure direction and link_group is valid
if direction == nil or link_group == nil then
return false
end
for i, v in ipairs(module.filters) do
if v.direction == direction and v.link_group == link_group then
return v.smart
end
end
return false
end
function module.is_filter_disabled (direction, link_group)
-- Make sure direction and link_group is valid
if direction == nil or link_group == nil then
return false
end
for i, v in ipairs(module.filters) do
if v.direction == direction and v.link_group == link_group then
return v.disabled
end
end
return false
end
function module.is_filter_targetable (direction, link_group)
-- Make sure direction and link_group is valid
if direction == nil or link_group == nil then
return false
end
for i, v in ipairs(module.filters) do
if v.direction == direction and v.link_group == link_group then
return v.targetable
end
end
return false
end
function module.get_filter_target (direction, link_group)
-- Make sure direction and link_group are valid
if direction == nil or link_group == nil then
return nil
end
-- Find the current filter
local filter = nil
local index = nil
for i, v in ipairs(module.filters) do
if v.direction == direction and
v.link_group == link_group and
not v.disabled and
v.smart then
filter = v
index = i
break
end
end
if filter == nil then
return nil
end
-- Return the next filter with matching target
for i, v in ipairs(module.filters) do
if v.direction == direction and
v.media_type == filter.media_type and
v.name ~= filter.name and
v.link_group ~= link_group and
not v.disabled and
v.smart and
((v.target == nil and filter.target == nil) or
(v.target ~= nil and filter.target ~= nil and v.target.id == filter.target.id)) and
i > index then
return v.main_si
end
end
-- Otherwise return the filter destination target
return filter.target
end
function module.get_filter_from_target (direction, media_type, si_target)
local target = si_target
-- Make sure direction and media_type are valid
if direction == nil or media_type == nil then
return nil
end
-- If si_target is a filter, find it and use its target
if si_target then
local target_node = si_target:get_associated_proxy ("node")
local target_link_group = target_node.properties ["node.link-group"]
if target_link_group ~= nil then
local filter = nil
for i, v in ipairs(module.filters) do
if v.direction == direction and
v.media_type == media_type and
v.link_group == target_link_group and
not v.disabled and
v.smart then
filter = v
break
end
end
if filter == nil then
return nil
end
target = filter.target
end
end
-- Find the first filter matching target
for i, v in ipairs(module.filters) do
if v.direction == direction and
v.media_type == media_type and
not v.disabled and
v.smart and
((v.target ~= nil and target ~= nil and v.target.id == target.id) or
(target == nil and v.targetless)) then
return v.main_si
end
end
return nil
end
return module

View File

@@ -0,0 +1,431 @@
-- WirePlumber
-- Copyright © 2022 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
-- SPDX-License-Identifier: MIT
-- Script is a Lua Module of linking Lua utility functions
local cutils = require ("common-utils")
local lutils = {
si_flags = {},
priority_media_role_link = {},
}
function lutils.get_flags (self, si_id)
if not self.si_flags [si_id] then
self.si_flags [si_id] = {}
end
return self.si_flags [si_id]
end
function lutils.clear_flags (self, si_id)
self.si_flags [si_id] = nil
end
function getprio (link)
return tonumber (link.properties ["policy.role-based.priority"]) or 0
end
function getplugged (link)
return tonumber (link.properties ["item.plugged.usec"]) or 0
end
function lutils.getAction (pmrl, link)
local props = pmrl.properties
if getprio (pmrl) == getprio (link) then
return props ["policy.role-based.action.same-priority"] or "mix"
else
return props ["policy.role-based.action.lower-priority"] or "mix"
end
end
-- if the link happens to be priority one, clear it and find the next
-- priority.
function lutils.clearPriorityMediaRoleLink (link)
local lprops = link.properties
local lmc = lprops ["target.media.class"]
pmrl = lutils.getPriorityMediaRoleLink (lmc)
-- only proceed if the link happens to be priority one.
if pmrl ~= link then
return
end
local prio_link = nil
local prio = 0
local plugged = 0
for l in cutils.get_object_manager ("session-item"):iterate {
type = "SiLink",
Constraint { "item.factory.name", "=", "si-standard-link", type = "pw-global" },
Constraint { "is.role.policy.link", "=", true },
Constraint { "target.media.class", "=", lmc },
} do
local props = l.properties
-- dont consider this link as it is about to be removed.
if pmrl == l then
goto continue
end
if getprio (link) > prio or
(getprio (link) == prio and getplugged (link) > plugged) then
prio = getprio (l)
plugged = getplugged (l)
prio_link = l
end
::continue::
end
if prio_link then
setPriorityMediaRoleLink (lmc, prio_link)
else
setPriorityMediaRoleLink (lmc, nil)
end
end
-- record priority media role link
function lutils.updatePriorityMediaRoleLink (link)
local lprops = link.properties
local mc = lprops ["target.media.class"]
if not lutils.priority_media_role_link [mc] then
setPriorityMediaRoleLink (mc, link)
return
end
pmrl = lutils.getPriorityMediaRoleLink (mc)
if getprio (link) > getprio (pmrl) or
(getprio (link) == getprio (pmrl) and getplugged (link) >= getplugged (pmrl)) then
setPriorityMediaRoleLink (mc, link)
end
end
function lutils.getPriorityMediaRoleLink (lmc)
return lutils.priority_media_role_link [lmc]
end
function setPriorityMediaRoleLink (lmc, link)
lutils.priority_media_role_link [lmc] = link
if link then
Log.debug (
string.format ("update priority link(%d) media role(\"%s\") priority(%d)",
link.id, link.properties ["media.role"], getprio (link)))
else
Log.debug ("clear priority media role")
end
end
function lutils.is_role_policy_target (si_props, target_props)
-- role-based policy links are those that link to targets with
-- policy.role-based.target = true, unless the stream is a monitor
-- (usually pavucontrol) or the stream is linking to the monitor ports
-- of a sink (both are "input")
return Core.test_feature ("hooks.linking.role-based.rescan")
and cutils.parseBool (target_props["policy.role-based.target"])
and not cutils.parseBool (si_props ["stream.monitor"])
and si_props["item.node.direction"] ~= target_props["item.node.direction"]
end
function lutils.unwrap_select_target_event (self, event)
local source = event:get_source ()
local si = event:get_subject ()
local target = event:get_data ("target")
local om = source:call ("get-object-manager", "session-item")
local si_id = si.id
return source, om, si, si.properties, self:get_flags (si_id), target
end
function lutils.canPassthrough (si, si_target)
local props = si.properties
local tprops = si_target.properties
-- both nodes must support encoded formats
if not cutils.parseBool (props ["item.node.supports-encoded-fmts"])
or not cutils.parseBool (tprops ["item.node.supports-encoded-fmts"]) then
return false
end
-- make sure that the nodes have at least one common non-raw format
local n1 = si:get_associated_proxy ("node")
local n2 = si_target:get_associated_proxy ("node")
for p1 in n1:iterate_params ("EnumFormat") do
local p1p = p1:parse ()
if p1p.properties.mediaSubtype ~= "raw" then
for p2 in n2:iterate_params ("EnumFormat") do
if p1:filter (p2) then
return true
end
end
end
end
return false
end
function lutils.checkFollowDefault (si, si_target)
-- If it got linked to the default target that is defined by node
-- props but not metadata, start ignoring the node prop from now on.
-- This is what Pulseaudio does.
--
-- Pulseaudio skips here filter streams (i->origin_sink and
-- o->destination_source set in PA). Pipewire does not have a flag
-- explicitly for this, but we can use presence of node.link-group.
local si_props = si.properties
local target_props = si_target.properties
local reconnect = not cutils.parseBool (si_props ["node.dont-reconnect"])
local is_filter = (si_props ["node.link-group"] ~= nil)
if reconnect and not is_filter then
local def_id = cutils.getDefaultNode (si_props,
cutils.getTargetDirection (si_props))
if target_props ["node.id"] == tostring (def_id) then
local metadata = cutils.get_default_metadata_object ()
-- Set target.node, for backward compatibility
metadata:set (tonumber
(si_props ["node.id"]), "target.node", "Spa:Id", "-1")
Log.info (si, "... set metadata to follow default")
end
end
end
function lutils.lookupLink (si_id, si_target_id)
local link = cutils.get_object_manager ("session-item"):lookup {
type = "SiLink",
Constraint { "out.item.id", "=", si_id },
Constraint { "in.item.id", "=", si_target_id }
}
if not link then
link = cutils.get_object_manager ("session-item"):lookup {
type = "SiLink",
Constraint { "in.item.id", "=", si_id },
Constraint { "out.item.id", "=", si_target_id }
}
end
return link
end
function lutils.isLinked (si_target)
local target_id = si_target.id
local linked = false
local exclusive = false
for l in cutils.get_object_manager ("session-item"):iterate {
type = "SiLink",
} do
local p = l.properties
local out_id = tonumber (p ["out.item.id"])
local in_id = tonumber (p ["in.item.id"])
linked = (out_id == target_id) or (in_id == target_id)
if linked then
exclusive = cutils.parseBool (p ["exclusive"]) or cutils.parseBool (p ["passthrough"])
break
end
end
return linked, exclusive
end
function lutils.getNodePeerId (node_id)
for l in cutils.get_object_manager ("link"):iterate() do
local p = l.properties
local in_id = tonumber(p["link.input.node"])
local out_id = tonumber(p["link.output.node"])
if in_id == node_id then
return out_id
elseif out_id == node_id then
return in_id
end
end
return nil
end
function lutils.canLink (properties, si_target)
local target_props = si_target.properties
-- nodes must have the same media type
if properties ["media.type"] ~= target_props ["media.type"] then
return false
end
local function isMonitor(properties)
return properties ["item.node.direction"] == "input" and
cutils.parseBool (properties ["item.features.monitor"]) and
not cutils.parseBool (properties ["item.features.no-dsp"]) and
properties ["item.factory.name"] == "si-audio-adapter"
end
-- nodes must have opposite direction, or otherwise they must be both input
-- and the target must have a monitor (so the target will be used as a source)
if properties ["item.node.direction"] == target_props ["item.node.direction"]
and not isMonitor (target_props) then
return false
end
-- check link group
local function canLinkGroupCheck(link_group, si_target, hops)
local target_props = si_target.properties
local target_link_group = target_props ["node.link-group"]
if hops == 8 then
return false
end
-- allow linking if target has no link-group property
if not target_link_group then
return true
end
-- do not allow linking if target has the same link-group
if link_group == target_link_group then
return false
end
-- make sure target is not linked with another node with same link group
-- start by locating other nodes in the target's link-group, in opposite direction
for n in cutils.get_object_manager ("session-item"):iterate {
type = "SiLinkable",
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "id", "!", si_target.id, type = "gobject" },
Constraint { "item.node.direction", "!", target_props ["item.node.direction"] },
Constraint { "node.link-group", "=", target_link_group },
} do
-- iterate their peers and return false if one of them cannot link
for silink in cutils.get_object_manager ("session-item"):iterate {
type = "SiLink",
} do
local out_id = tonumber (silink.properties ["out.item.id"])
local in_id = tonumber (silink.properties ["in.item.id"])
if out_id == n.id or in_id == n.id then
local peer_id = (out_id == n.id) and in_id or out_id
local peer = cutils.get_object_manager ("session-item"):lookup {
type = "SiLinkable",
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "id", "=", peer_id, type = "gobject" },
}
if peer and not canLinkGroupCheck (link_group, peer, hops + 1) then
return false
end
end
end
end
return true
end
local link_group = properties ["node.link-group"]
if link_group then
return canLinkGroupCheck (link_group, si_target, 0)
end
return true
end
function lutils.findDefaultLinkable (si)
local si_props = si.properties
local target_direction = cutils.getTargetDirection (si_props)
local def_node_id = cutils.getDefaultNode (si_props, target_direction)
return cutils.get_object_manager ("session-item"):lookup {
type = "SiLinkable",
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "node.id", "=", tostring (def_node_id) }
}
end
function lutils.checkPassthroughCompatibility (si, si_target)
local si_must_passthrough =
cutils.parseBool (si.properties ["item.node.encoded-only"])
local si_target_must_passthrough =
cutils.parseBool (si_target.properties ["item.node.encoded-only"])
local can_passthrough = lutils.canPassthrough (si, si_target)
if (si_must_passthrough or si_target_must_passthrough)
and not can_passthrough then
return false, can_passthrough
end
return true, can_passthrough
end
-- Does the target device have any active/available paths/routes to
-- the physical device(spkr/mic/cam)?
function lutils.haveAvailableRoutes (si_props)
local card_profile_device = si_props ["card.profile.device"]
local device_id = si_props ["device.id"]
local device = device_id and cutils.get_object_manager ("device"):lookup {
Constraint { "bound-id", "=", device_id, type = "gobject" },
}
if not card_profile_device or not device then
return true
end
local found = 0
local avail = 0
-- First check "SPA_PARAM_Route" if there are any active devices
-- in an active profile.
for p in device:iterate_params ("Route") do
local route = cutils.parseParam (p, "Route")
if not route then
goto skip_route
end
if (route.device ~= tonumber (card_profile_device)) then
goto skip_route
end
if (route.available == "no") then
return false
end
do return true end
::skip_route::
end
-- Second check "SPA_PARAM_EnumRoute" if there is any route that
-- is available if not active.
for p in device:iterate_params ("EnumRoute") do
local route = cutils.parseParam (p, "EnumRoute")
if not route then
goto skip_enum_route
end
if not cutils.arrayContains
(route.devices, tonumber (card_profile_device)) then
goto skip_enum_route
end
found = found + 1;
if (route.available ~= "no") then
avail = avail + 1
end
::skip_enum_route::
end
if found == 0 then
return true
end
if avail > 0 then
return true
end
return false
end
function lutils.sendClientError (event, node, code, message)
local source = event:get_source ()
local client_id = node.properties ["client.id"]
if client_id then
local clients_om = source:call ("get-object-manager", "client")
local client = clients_om:lookup {
Constraint { "bound-id", "=", client_id, type = "gobject" }
}
if client then
client:send_error (node ["bound-id"], code, message)
end
end
end
return lutils

View File

@@ -0,0 +1,197 @@
-- WirePlumber
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
-- SPDX-License-Identifier: MIT
-- Script is a Lua Module of monitor Lua utility functions
log = Log.open_topic ("s-monitors-utils")
local mutils = {
cam_data = {}
}
-- finds out if any of the managed objects(nodes of a device or devices of
-- device enumerator) has duplicate values
function mutils.find_duplicate (parent, id, property, value)
for i = 0, id - 1, 1 do
local obj = parent:get_managed_object (i)
if obj and obj.properties[property] == value then
return true
end
end
return false
end
function get_cam_data(self, dev_id)
if not self.cam_data[dev_id] then
self.cam_data[dev_id] = {}
self.cam_data[dev_id]["libcamera"] = {}
self.cam_data[dev_id]["v4l2"] = {}
end
return self.cam_data[dev_id]
end
function parse_devids_get_cam_data(self, devids)
local dev_ids_json = Json.Raw(devids)
local dev_ids_table = {}
if dev_ids_json:is_array() then
dev_ids_table = dev_ids_json:parse()
else
-- to maintain the backward compatibility with earlier pipewire versions.
for dev_id_str in devids:gmatch("%S+") do
local dev_id = tonumber(dev_id_str)
if dev_id then
table.insert(dev_ids_table, dev_id)
end
end
end
local dev_num = nil
-- `device.devids` is a json array of device numbers
for _, dev_id_str in ipairs(dev_ids_table) do
local dev_id = tonumber(dev_id_str)
if not dev_id then
log:notice ("invalid device number")
return
end
log:debug ("Working on device " .. dev_id)
local dev_cam_data = get_cam_data (self, dev_id)
if not dev_num then
dev_num = dev_id
if #dev_ids_table > 1 then
-- libcam node can some times use more tha one V4L2 devices, in this
-- case, return the first device id and mark rest of the them as peers
-- to the first one.
log:debug ("Device " .. dev_id .. " uses multi V4L2 devices")
dev_cam_data.uses_multi_v4l2_devices = true
end
else
log:debug ("Device " .. dev_id .. " is peer to " .. dev_num)
dev_cam_data.peer_id = dev_num
end
end
if dev_num then
return self.cam_data[dev_num], dev_num
end
end
function mutils.clear_cam_data (self, dev_num)
local dev_cam_data = self.cam_data[dev_num]
if not dev_cam_data then
return
end
if dev_cam_data.uses_multi_v4l2_devices then
for dev_id, cam_data_ in pairs(self.cam_data) do
if cam_data_.peer_id == dev_num then
log:debug("clear " .. dev_id .. " it is peer to " .. dev_num)
self.cam_data[dev_id] = nil
end
end
end
self.cam_data[dev_num] = nil
end
function mutils.create_cam_node(self, dev_num)
local api = nil
local cam_data = get_cam_data (self, dev_num)
if cam_data["v4l2"].enum_status and cam_data["libcamera"].enum_status then
if cam_data.uses_multi_v4l2_devices then
api = "libcamera"
elseif cam_data.peer_id ~= nil then
-- no need to create node for peer
log:notice ("timer expired for peer device " .. dev_num)
return
elseif cam_data.is_device_uvc then
api = "v4l2"
else
api = "libcamera"
end
else
api = cam_data["v4l2"].enum_status and "v4l2" or "libcamera"
end
log:info (string.format ("create \"%s\" node for device:%s", api,
cam_data.dev_path))
source = source or Plugin.find ("standard-event-source")
local e = source:call ("create-event", "create-" .. api .. "-device-node",
cam_data[api].parent, nil)
e:set_data ("factory", cam_data[api].factory)
e:set_data ("node-properties", cam_data[api].properties)
e:set_data ("node-sub-id", cam_data[api].id)
EventDispatcher.push_event (e)
self:clear_cam_data (dev_num)
end
-- arbitrates between v4l2 and libcamera on who gets to create the device node
-- for a device, logic is based on the device number of the device given by both
-- the parties.
function mutils.register_cam_node (self, parent, id, factory, properties)
local api = properties["device.api"]
local dev_ids = properties["device.devids"]
log:debug(api .. " reported " .. dev_ids)
local cam_data, dev_num = parse_devids_get_cam_data(self, dev_ids)
if not cam_data then
log:notice (string.format ("device numbers invalid for %s device:%s",
api, properties["device.name"]))
return false
end
-- only v4l2 can give this info
if properties["api.v4l2.cap.driver"] == "uvcvideo" then
log:debug ("Device " .. dev_num .. " is a UVC device")
cam_data.is_device_uvc = true
end
-- only v4l2 can give this info
if properties["api.v4l2.path"] then
cam_data.dev_path = properties["api.v4l2.path"]
end
local cam_api_data = cam_data[api]
cam_api_data.enum_status = true
-- cache info, it comes handy when creating node
cam_api_data.parent = parent
cam_api_data.id = id
cam_api_data.name = properties["device.name"]
cam_api_data.factory = factory
cam_api_data.properties = properties
local other_api = api == "v4l2" and "libcamera" or "v4l2"
if cam_api_data.enum_status and not cam_data[other_api].enum_status then
log:trace (string.format ("\"%s\" armed a timer for %d", api, dev_num))
cam_data.source = Core.timeout_add (
Settings.get_int ("monitor.camera-discovery-timeout"), function()
log:trace (string.format ("\"%s\" armed timer expired for %d", api, dev_num))
self:create_cam_node (dev_num)
cam_data.source = nil
end)
elseif cam_data.source then
log:trace (string.format ("\"%s\" disarmed timer for %d", api, dev_num))
cam_data.source:destroy ()
cam_data.source = nil
self:create_cam_node (dev_num)
else
log:notice (string.format ("\"%s\" calling after timer expiry for %d:%s%s",
api, dev_num, cam_data.dev_path,
(cam_data.is_device_uvc and "(uvc)" or "")))
end
return true
end
return mutils

View File

@@ -0,0 +1,18 @@
-- WirePlumber
--
-- Copyright © 2024 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
local module = {}
function module.get_session_priority (node_props)
local priority = node_props ["priority.session"]
-- fallback to driver priority if session priority is not set
if not priority then
priority = node_props ["priority.driver"]
end
return math.tointeger (priority) or 0
end
return module

View File

@@ -0,0 +1,115 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Traverse through all the possible targets to pick up target node.
lutils = require ("linking-utils")
cutils = require ("common-utils")
futils = require ("filter-utils")
log = Log.open_topic ("s-linking")
SimpleEventHook {
name = "linking/find-best-target",
after = { "linking/find-defined-target",
"linking/find-filter-target",
"linking/find-media-role-target",
"linking/find-default-target" },
before = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local source, om, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
-- bypass the hook if the target is already picked up
if target then
return
end
local target_direction = cutils.getTargetDirection (si_props)
local target_picked = nil
local target_can_passthrough = false
local target_priority = 0
local target_plugged = 0
log:info (si, string.format ("handling item: %s (%s)",
tostring (si_props ["node.name"]), tostring (si_props ["node.id"])))
for target in om:iterate {
type = "SiLinkable",
Constraint { "item.node.type", "=", "device" },
Constraint { "item.node.direction", "=", target_direction },
Constraint { "media.type", "=", si_props ["media.type"] },
} do
local target_props = target.properties
local target_node_id = target_props ["node.id"]
local si_target_node = target:get_associated_proxy ("node")
local si_target_link_group = si_target_node.properties ["node.link-group"]
local priority = tonumber (target_props ["priority.session"]) or 0
log:debug (string.format ("Looking at: %s (%s)",
tostring (target_props ["node.name"]),
tostring (target_node_id)))
-- Skip smart filters as best target
if si_target_link_group ~= nil and
futils.is_filter_smart (target_direction, si_target_link_group) then
Log.debug ("... ignoring smart filter as best target")
goto skip_linkable
end
if not lutils.canLink (si_props, target) then
log:debug ("... cannot link, skip linkable")
goto skip_linkable
end
if not lutils.haveAvailableRoutes (target_props) then
log:debug ("... does not have routes, skip linkable")
goto skip_linkable
end
local passthrough_compatible, can_passthrough =
lutils.checkPassthroughCompatibility (si, target)
if not passthrough_compatible then
log:debug ("... passthrough is not compatible, skip linkable")
goto skip_linkable
end
local plugged = tonumber (target_props ["item.plugged.usec"]) or 0
log:debug ("... priority:" .. tostring (priority) .. ", plugged:" .. tostring (plugged))
-- (target_picked == NULL) --> make sure atleast one target is picked.
-- (priority > target_priority) --> pick the highest priority linkable(node)
-- target.
-- (priority == target_priority and plugged > target_plugged) --> pick the
-- latest connected/plugged(in time) linkable(node) target.
if (target_picked == nil or
priority > target_priority or
(priority == target_priority and plugged > target_plugged)) then
log:debug ("... picked")
target_picked = target
target_can_passthrough = can_passthrough
target_priority = priority
target_plugged = plugged
end
::skip_linkable::
end
if target_picked then
log:info (si,
string.format ("... best target picked: %s (%s), can_passthrough:%s",
tostring (target_picked.properties ["node.name"]),
tostring (target_picked.properties ["node.id"]),
tostring (target_can_passthrough)))
si_flags.can_passthrough = target_can_passthrough
event:set_data ("target", target_picked)
end
end
}:register ()

View File

@@ -0,0 +1,58 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Check if default nodes can be picked up as target node.
lutils = require ("linking-utils")
log = Log.open_topic ("s-linking")
SimpleEventHook {
name = "linking/find-default-target",
after = { "linking/find-defined-target",
"linking/find-filter-target",
"linking/find-media-role-target" },
before = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local source, om, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
-- bypass the hook if the target is already picked up
if target then
return
end
local target_picked = false
log:info (si, string.format ("handling item: %s (%s)",
tostring (si_props ["node.name"]), tostring (si_props ["node.id"])))
target = lutils.findDefaultLinkable (si)
local can_passthrough, passthrough_compatible
if target then
passthrough_compatible, can_passthrough =
lutils.checkPassthroughCompatibility (si, target)
if lutils.canLink (si_props, target) and passthrough_compatible then
target_picked = true;
end
end
if target_picked then
log:info (si,
string.format ("... default target picked: %s (%s), can_passthrough:%s",
tostring (target.properties ["node.name"]),
tostring (target.properties ["node.id"]),
tostring (can_passthrough)))
si_flags.can_passthrough = can_passthrough
event:set_data ("target", target)
end
end
}:register ()

View File

@@ -0,0 +1,131 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Check if the target node is defined explicitly.
-- This defination can be done in two ways.
-- 1. "node.target"/"target.object" in the node properties
-- 2. "target.node"/"target.object" in the default metadata
lutils = require ("linking-utils")
cutils = require ("common-utils")
log = Log.open_topic ("s-linking")
SimpleEventHook {
name = "linking/find-defined-target",
before = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local source, om, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
-- bypass the hook if the target is already picked up
if target then
return
end
log:info (si, string.format ("handling item %d: %s (%s)", si.id,
tostring (si_props ["node.name"]), tostring (si_props ["node.id"])))
local metadata = Settings.get_boolean ("linking.allow-moving-streams") and
cutils.get_default_metadata_object ()
local dont_fallback = cutils.parseBool (si_props ["node.dont-fallback"])
local dont_move = cutils.parseBool (si_props ["node.dont-move"])
local target_key
local target_value = nil
local node_defined = false
local target_picked = nil
if si_props ["target.object"] ~= nil then
target_value = si_props ["target.object"]
target_key = "object.serial"
node_defined = true
elseif si_props ["node.target"] ~= nil then
target_value = si_props ["node.target"]
target_key = "node.id"
node_defined = true
end
if metadata and not dont_move then
local id = metadata:find (si_props ["node.id"], "target.object")
if id ~= nil then
target_value = id
target_key = "object.serial"
node_defined = false
else
id = metadata:find (si_props ["node.id"], "target.node")
if id ~= nil then
target_value = id
target_key = "node.id"
node_defined = false
end
end
end
if target_value == "-1" then
target_picked = false
target = nil
elseif target_value and tonumber (target_value) then
target = om:lookup {
type = "SiLinkable",
Constraint { target_key, "=", target_value },
}
if target and lutils.canLink (si_props, target) then
target_picked = true
end
elseif target_value then
for lnkbl in om:iterate { type = "SiLinkable" } do
local target_props = lnkbl.properties
if (target_props ["node.name"] == target_value or
target_props ["object.path"] == target_value) and
target_props ["item.node.direction"] == cutils.getTargetDirection (si_props) and
lutils.canLink (si_props, lnkbl) then
target_picked = true
target = lnkbl
break
end
end
end
local can_passthrough, passthrough_compatible
if target then
passthrough_compatible, can_passthrough =
lutils.checkPassthroughCompatibility (si, target)
if not passthrough_compatible then
target = nil
end
end
si_flags.has_defined_target = false
if target_picked and target then
log:info (si,
string.format ("... defined target picked: %s (%s), can_passthrough:%s",
tostring (target.properties ["node.name"]),
tostring (target.properties ["node.id"]),
tostring (can_passthrough)))
si_flags.has_node_defined_target = node_defined
si_flags.can_passthrough = can_passthrough
si_flags.has_defined_target = true
event:set_data ("target", target)
elseif target_value and dont_fallback then
-- send error to client and destroy node if linger is not set
local linger = cutils.parseBool (si_props ["node.linger"])
if not linger then
local node = si:get_associated_proxy ("node")
lutils.sendClientError (event, node, -2, "defined target not found")
node:request_destroy ()
log:info(si, "... destroyed node as defined target was not found")
else
log:info(si, "... waiting for defined target as dont-fallback is set")
end
event:stop_processing ()
end
end
}:register ()

View File

@@ -0,0 +1,92 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Check if the target node is a filter target.
lutils = require ("linking-utils")
cutils = require ("common-utils")
futils = require ("filter-utils")
log = Log.open_topic ("s-linking")
function findFilterTarget (si, om)
local node = si:get_associated_proxy ("node")
local link_group = node.properties ["node.link-group"]
local target_id = -1
-- return nil if session item is not a filter node
if link_group == nil then
return nil, false
end
-- return nil if filter is not smart
local direction = cutils.getTargetDirection (si.properties)
if not futils.is_filter_smart (direction, link_group) then
return nil, false
end
-- get the filter target
return futils.get_filter_target (direction, link_group), true
end
SimpleEventHook {
name = "linking/find-filter-target",
after = "linking/find-defined-target",
before = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local source, om, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
-- bypass the hook if the target is already picked up
if target then
return
end
local dont_fallback = cutils.parseBool (si_props ["node.dont-fallback"])
local target_picked = false
local allow_fallback
log:info (si, string.format ("handling item %d: %s (%s)", si.id,
tostring (si_props ["node.name"]), tostring (si_props ["node.id"])))
target, is_smart_filter = findFilterTarget (si, om)
local can_passthrough, passthrough_compatible
if target then
passthrough_compatible, can_passthrough =
lutils.checkPassthroughCompatibility (si, target)
if lutils.canLink (si_props, target) and passthrough_compatible then
target_picked = true
end
end
if target_picked and target then
log:info (si,
string.format ("... filter target picked: %s (%s), can_passthrough:%s",
tostring (target.properties ["node.name"]),
tostring (target.properties ["node.id"]),
tostring (can_passthrough)))
si_flags.can_passthrough = can_passthrough
event:set_data ("target", target)
elseif is_smart_filter and dont_fallback then
-- send error to client and destroy node if linger is not set
local linger = cutils.parseBool (si_props ["node.linger"])
if not linger then
local node = si:get_associated_proxy ("node")
lutils.sendClientError (event, node, -2, "smart filter defined target not found")
node:request_destroy ()
log:info(si, "... destroyed node as smart filter defined target was not found")
else
log:info(si, "... waiting for smart filter defined target as dont-fallback is set")
end
event:stop_processing ()
end
end
}:register ()

View File

@@ -0,0 +1,70 @@
-- WirePlumber
--
-- Copyright © 2024 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Select the media role target
cutils = require("common-utils")
lutils = require("linking-utils")
log = Log.open_topic("s-linking")
SimpleEventHook {
name = "linking/find-media-role-target",
after = { "linking/find-defined-target",
"linking/find-filter-target" },
before = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local _, om, si, si_props, _, target =
lutils:unwrap_select_target_event (event)
local target_direction = cutils.getTargetDirection (si_props)
local media_role = si_props["media.role"]
-- bypass the hook if the target is already picked up or if the role is not
-- defined
if target or media_role == nil then
return
end
log:info (si, string.format ("handling item %d: %s (%s) role (%s)", si.id,
tostring (si_props ["node.name"]), tostring (si_props ["node.id"]), media_role))
for si_target in om:iterate {
type = "SiLinkable",
Constraint { "item.node.direction", "=", target_direction },
Constraint { "device.intended-roles", "+" },
Constraint { "media.type", "=", si_props["media.type"] },
} do
local roles_json = si_target.properties["device.intended-roles"]
local roles_table = Json.Raw(roles_json):parse()
for _, target_role in ipairs(roles_table) do
if target_role == media_role then
target = si_target
break
end
end
if target then
break
end
end
-- set target
if target ~= nil then
log:info(si,
string.format("... media role target picked: %s (%s)",
tostring(target.properties["node.name"]),
tostring(target.properties["node.id"])))
event:set_data("target", target)
end
end
}:register()

View File

@@ -0,0 +1,37 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- example of a user injectible hook to link a node to a custom target
lutils = require ("linking-utils")
log = Log.open_topic ("s-linking")
SimpleEventHook {
name = "linking/sample-find-user-target",
before = "linking/find-defined-target",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local source, om, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
-- bypass the hook if the target is already picked up
if target then
return
end
log:info (si, "in find-user-target")
-- implement logic here to find a suitable target
-- store the found target on the event,
-- the next hooks will take care of linking
event:set_data ("target", target)
end
}:register ()

View File

@@ -0,0 +1,92 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Check if the target node is a filter target.
lutils = require ("linking-utils")
cutils = require ("common-utils")
futils = require ("filter-utils")
log = Log.open_topic ("s-linking")
SimpleEventHook {
name = "linking/get-filter-from-target",
after = { "linking/find-defined-target",
"linking/find-filter-target",
"linking/find-media-role-target",
"linking/find-default-target",
"linking/find-best-target" },
before = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local source, om, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
-- bypass the hook if the target was not found or if it is a role-based policy target
if target == nil or lutils.is_role_policy_target (si_props, target.properties) then
return
end
-- bypass the hook if the session item is a smart filter
local node = si:get_associated_proxy ("node")
local node_props = node.properties
local link_group = node_props ["node.link-group"]
local target_direction = cutils.getTargetDirection (si.properties)
if link_group ~= nil and
futils.is_filter_smart (target_direction, link_group) then
return
end
-- bypass the hook if target is defined, is a filter and is targetable
local target_node = target:get_associated_proxy ("node")
local target_node_props = target_node.properties
local target_link_group = target_node_props ["node.link-group"]
if target_link_group ~= nil and si_flags.has_defined_target then
if futils.is_filter_smart (target_direction, target_link_group) and
not futils.is_filter_disabled (target_direction, target_link_group) and
futils.is_filter_targetable (target_direction, target_link_group) then
return
end
end
-- Get the filter from the given target if it exists, otherwise get the
-- default filter, but only if target was not defined
local media_type = si_props["media.type"]
local filter_target = futils.get_filter_from_target (target_direction, media_type, target)
if filter_target ~= nil then
target = filter_target
log:info (si, "... got filter for given target")
elseif filter_target == nil and not si_flags.has_defined_target then
filter_target = futils.get_filter_from_target (target_direction, media_type, nil)
if filter_target ~= nil then
target = filter_target
log:info (si, "... got default filter for given target")
end
end
local can_passthrough, passthrough_compatible
if target ~= nil then
passthrough_compatible, can_passthrough =
lutils.checkPassthroughCompatibility (si, target)
if lutils.canLink (si_props, target) and passthrough_compatible then
target_picked = true;
end
end
if target_picked then
log:info (si,
string.format ("... target picked: %s (%s), can_passthrough:%s",
tostring (target.properties ["node.name"]),
tostring (target.properties ["node.id"]),
tostring (can_passthrough)))
si_flags.can_passthrough = can_passthrough
event:set_data ("target", target)
end
end
}:register ()

View File

@@ -0,0 +1,157 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Links a session item to the target that has been previously selected.
-- This is meant to be the last hook in the select-target chain.
lutils = require ("linking-utils")
cutils = require ("common-utils")
log = Log.open_topic ("s-linking")
AsyncEventHook {
name = "linking/link-target",
after = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
steps = {
start = {
next = "none",
execute = function (event, transition)
local source, om, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
if not target then
-- bypass the hook, nothing to link to.
transition:advance ()
return
end
local target_props = target.properties
local out_item = nil
local in_item = nil
local si_link = nil
local passthrough = si_flags.can_passthrough
log:info (si, string.format ("handling item %d: %s (%s)", si.id,
tostring (si_props ["node.name"]), tostring (si_props ["node.id"])))
local exclusive = cutils.parseBool (si_props ["node.exclusive"])
-- break rescan if tried more than 5 times with same target
if si_flags.failed_peer_id ~= nil and
si_flags.failed_peer_id == target.id and
si_flags.failed_count ~= nil and
si_flags.failed_count > 5 then
transition:return_error ("tried to link on last rescan, not retrying "
.. tostring (si_link))
return
end
if si_props["item.node.direction"] == "output" then
-- playback
out_item = si
in_item = target
else
-- capture
in_item = si
out_item = target
end
local is_role_policy_link = lutils.is_role_policy_target (si_props, target_props)
log:info (si,
string.format ("link %s <-> %s passthrough:%s, exclusive:%s, media role link:%s",
tostring (si_props ["node.name"]),
tostring (target_props ["node.name"]),
tostring (passthrough),
tostring (exclusive),
tostring (is_role_policy_link)))
-- create and configure link
si_link = SessionItem ("si-standard-link")
if not si_link:configure {
["out.item"] = out_item,
["in.item"] = in_item,
["passthrough"] = passthrough,
["exclusive"] = exclusive,
["out.item.port.context"] = "output",
["in.item.port.context"] = "input",
["media.role"] = si_props["media.role"],
["target.media.class"] = target_props["media.class"],
["policy.role-based.priority"] = target_props["policy.role-based.priority"],
["policy.role-based.action.same-priority"] = target_props["policy.role-based.action.same-priority"],
["policy.role-based.action.lower-priority"] = target_props["policy.role-based.action.lower-priority"],
["is.role.policy.link"] = is_role_policy_link,
["main.item.id"] = si.id,
["target.item.id"] = target.id,
} then
transition:return_error ("failed to configure si-standard-link "
.. tostring (si_link))
return
end
local ids = {si.id, target.id}
si_link:connect("link-error", function (_, error_msg)
for _, id in ipairs (ids) do
local si = om:lookup {
Constraint { "id", "=", id, type = "gobject" },
}
if si then
local node = si:get_associated_proxy ("node")
lutils.sendClientError(event, node, -32, error_msg)
end
end
end)
-- register
si_flags.was_handled = true
si_flags.peer_id = target.id
si_flags.failed_peer_id = target.id
if si_flags.failed_count ~= nil then
si_flags.failed_count = si_flags.failed_count + 1
else
si_flags.failed_count = 1
end
si_link:register ()
log:debug (si_link, "registered link between "
.. tostring (si) .. " and " .. tostring (target))
-- only activate non role-based policy links because their activation is
-- handled by rescan-media-role-links.lua
if not is_role_policy_link then
si_link:activate (Feature.SessionItem.ACTIVE, function (l, e)
if e then
transition:return_error (tostring (l) .. " link failed: "
.. tostring (e))
if si_flags ~= nil then
si_flags.peer_id = nil
end
l:remove ()
else
si_flags.failed_peer_id = nil
if si_flags.peer_id == nil then
si_flags.peer_id = target.id
end
si_flags.failed_count = 0
log:debug (l, "activated link between "
.. tostring (si) .. " and " .. tostring (target))
transition:advance ()
end
end)
else
lutils.updatePriorityMediaRoleLink(si_link)
transition:advance ()
end
end,
},
},
}:register ()

View File

@@ -0,0 +1,125 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- remove the existing link if needed, check the properties of target, which
-- indicate it is not available for linking. If no target is available, send
-- down an error to the corresponding client.
lutils = require ("linking-utils")
cutils = require ("common-utils")
log = Log.open_topic ("s-linking")
SimpleEventHook {
name = "linking/prepare-link",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-target" },
},
},
execute = function (event)
local source, _, si, si_props, si_flags, target =
lutils:unwrap_select_target_event (event)
local si_id = si.id
local reconnect = not cutils.parseBool (si_props ["node.dont-reconnect"])
local exclusive = cutils.parseBool (si_props ["node.exclusive"])
local si_must_passthrough = cutils.parseBool (si_props ["item.node.encoded-only"])
log:info (si, string.format ("handling item %d: %s (%s)", si_id,
tostring (si_props ["node.name"]), tostring (si_props ["node.id"])))
-- Check if item is linked to proper target, otherwise re-link
if si_flags.peer_id then
if target and si_flags.peer_id == target.id then
log:info (si, "... already linked to proper target")
-- Check this also here, in case in default targets changed
if Settings.get_boolean ("linking.follow-default-target") and
si_flags.has_node_defined_target then
lutils.checkFollowDefault (si, target)
end
target = nil
goto done
end
local link = lutils.lookupLink (si_id, si_flags.peer_id)
if reconnect then
if link ~= nil then
-- remove old link
if ((link:get_active_features () & Feature.SessionItem.ACTIVE) == 0)
then
-- remove also not yet activated links: they might never become
-- active, and we need not wait for it to become active
log:warning (link, "Link was not activated before removing")
end
si_flags.peer_id = nil
link:remove ()
log:info (si, "... moving to new target")
end
else
if link ~= nil then
log:info (si, "... dont-reconnect, not moving")
goto done
end
end
end
-- if the stream has dont-reconnect and was already linked before,
-- don't link it to a new target
if not reconnect and si_flags.was_handled then
target = nil
goto done
end
-- check target's availability
if target then
local target_is_linked, target_is_exclusive = lutils.isLinked (target)
if target_is_exclusive then
log:info (si, "... target is linked exclusively")
target = nil
end
if target_is_linked then
if exclusive or si_must_passthrough then
log:info (si, "... target is already linked, cannot link exclusively")
target = nil
else
-- disable passthrough, we can live without it
si_flags.can_passthrough = false
end
end
end
if not target then
log:info (si, "... target not found, reconnect:" .. tostring (reconnect))
local node = si:get_associated_proxy ("node")
if reconnect and si_flags.was_handled then
log:info (si, "... waiting reconnect")
return
end
local linger = cutils.parseBool (si_props ["node.linger"])
if linger then
log:info (si, "... node linger")
return
end
lutils.sendClientError (event, node, -2,
reconnect and "no target node available" or "target not found")
if not reconnect then
log:info (si, "... destroy node")
node:request_destroy ()
end
end
::done::
event:set_data ("target", target)
end
}:register ()

View File

@@ -0,0 +1,203 @@
-- WirePlumber
--
-- Copyright © 2024 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
lutils = require("linking-utils")
cutils = require("common-utils")
log = Log.open_topic("s-linking")
function restoreVolume (om, link)
setVolume(om, link, 1.0)
end
function duckVolume (om, link)
setVolume(om, link, Settings.get_float("linking.role-based.duck-level"))
end
function setVolume (om, link, level)
local lprops = link.properties
local media_role_si_id = nil
local dir = lprops ["item.node.direction"]
if dir == "output" then
media_role_si_id = lprops ["out.item.id"]
else
media_role_si_id = lprops ["in.item.id"]
end
local media_role_lnkbl = om:lookup {
type = "SiLinkable",
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "id", "=", media_role_si_id, type = "gobject" },
}
-- apply volume control on the stream node of the loopback module, instead of
-- the sink/source node as it simplyfies the volume ducking and
-- restoration.
local media_role_other_lnkbl = om:lookup {
type = "SiLinkable",
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "node.link-group", "=", media_role_lnkbl.properties ["node.link-group"] },
Constraint { "id", "!", media_role_lnkbl.id, type = "gobject" },
}
if media_role_other_lnkbl then
local n = media_role_other_lnkbl:get_associated_proxy("node")
if n then
log:info(string.format(".. %s volume of media role node \"%s(%d)\" to %f",
level < 1.0 and "duck" or "restore", n.properties ["node.name"],
n ["bound-id"], level))
local props = {
"Spa:Pod:Object:Param:Props",
"Props",
volume = level,
}
local param = Pod.Object(props)
n:set_param("Props", param)
end
end
end
function getSuspendPlaybackFromMetadata (om)
local suspend = false
local metadata = om:lookup {
type = "metadata",
Constraint { "metadata.name", "=", "default" },
}
if metadata then
local value = metadata:find(0, "suspend.playback")
if value then
suspend = value == "1" and true or false
end
end
return suspend
end
AsyncEventHook {
name = "linking/rescan-media-role-links",
interests = {
EventInterest {
-- on media client link added and removed
Constraint { "event.type", "c", "session-item-added", "session-item-removed" },
Constraint { "event.session-item.interface", "=", "link" },
Constraint { "is.role.policy.link", "=", true },
},
EventInterest {
-- on default metadata suspend.playback changed
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "=", "suspend.playback" },
}
},
steps = {
start = {
next = "none",
execute = function(event, transition)
local source, om, _, si_props, _, _ =
lutils:unwrap_select_target_event(event)
local metadata_om = source:call("get-object-manager", "metadata")
local suspend = getSuspendPlaybackFromMetadata(metadata_om)
local pending_activations = 0
local mc = si_props ["target.media.class"]
local pmrl_active = nil
pmrl = lutils.getPriorityMediaRoleLink(mc)
log:debug("Rescanning media role links...")
local function onMediaRoleLinkActivated (l, e)
local si_id = tonumber(l.properties ["main.item.id"])
local target_id = tonumber(l.properties ["target.item.id"])
local si_flags = lutils:get_flags(si_id)
if e then
log:warning(l, "failed to activate media role link: " .. e)
if si_flags ~= nil then
si_flags.peer_id = nil
end
l:remove()
else
log:info(l, "media role link activated successfully")
si_flags.failed_peer_id = nil
if si_flags.peer_id == nil then
si_flags.peer_id = target_id
end
si_flags.failed_count = 0
end
-- advance only when all pending activations are completed
pending_activations = pending_activations - 1
if pending_activations <= 0 then
log:info("All media role links activated")
transition:advance()
end
end
for link in om:iterate {
type = "SiLink",
Constraint { "is.role.policy.link", "=", true },
Constraint { "target.media.class", "=", mc },
} do
-- deactivate all links if suspend playback metadata is present
if suspend then
link:deactivate(Feature.SessionItem.ACTIVE)
end
local active = ((link:get_active_features() & Feature.SessionItem.ACTIVE) ~= 0)
log:debug(string.format(" .. looking at link(%d) active %s pmrl %s", link.id, tostring(active),
tostring(link == pmrl)))
if link == pmrl then
pmrl_active = active
restoreVolume(om, pmrl)
goto continue
end
local action = lutils.getAction(pmrl, link)
log:debug(string.format(" .. apply action(%s) on link(%d)", action, link.id, tostring(active)))
if action == "cork" then
if active then
link:deactivate(Feature.SessionItem.ACTIVE)
end
elseif action == "mix" then
if not active and not suspend then
pending_activations = pending_activations + 1
link:activate(Feature.SessionItem.ACTIVE, onMediaRoleLinkActivated)
end
restoreVolume(om, link)
elseif action == "duck" then
if not active and not suspend then
pending_activations = pending_activations + 1
link:activate(Feature.SessionItem.ACTIVE, onMediaRoleLinkActivated)
end
duckVolume(om, link)
else
log:warning("Unknown action: " .. action)
end
::continue::
end
if pmrl and not pmrl_active then
pending_activations = pending_activations + 1
pmrl:activate(Feature.SessionItem.ACTIVE, onMediaRoleLinkActivated)
restoreVolume(om, pmrl)
end
-- just advance transition if no pending activations are needed
if pending_activations <= 0 then
log:debug("All media role links rescanned")
transition:advance()
end
end,
},
},
}:register()

View File

@@ -0,0 +1,235 @@
-- WirePlumber
--
-- Copyright © 2020-2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Handle new linkables and trigger rescanning of the graph.
-- Rescan the graph by pushing new select-target events for
-- all linkables that need to be linked
-- Cleanup links when the linkables they are associated with are removed.
-- Also, cleanup flags attached to linkables.
lutils = require ("linking-utils")
cutils = require ("common-utils")
futils = require ("filter-utils")
log = Log.open_topic ("s-linking")
handles = {}
function checkFilter (si, om, handle_nonstreams)
-- always handle filters if handle_nonstreams is true, even if it is disabled
if handle_nonstreams then
return true
end
-- always return true if this is not a filter
local node = si:get_associated_proxy ("node")
local link_group = node.properties["node.link-group"]
if link_group == nil then
return true
end
local direction = cutils.getTargetDirection (si.properties)
-- always handle filters that are not smart
if not futils.is_filter_smart (direction, link_group) then
return true
end
-- dont handle smart filters that are disabled
return not futils.is_filter_disabled (direction, link_group)
end
function checkLinkable (si, om, handle_nonstreams)
local si_props = si.properties
-- For the rest of them, only handle stream session items
if not si_props or (si_props ["item.node.type"] ~= "stream"
and not handle_nonstreams) then
return false, si_props
end
-- check filters
if not checkFilter (si, om, handle_nonstreams) then
return false, si_props
end
return true, si_props
end
function unhandleLinkable (si, om)
local si_id = si.id
local valid, si_props = checkLinkable (si, om, true)
if not valid then
return
end
log:info (si, string.format ("unhandling item %d", si_id))
-- iterate over all the links in the graph and
-- remove any links associated with this item
for silink in om:iterate { type = "SiLink" } do
local out_id = tonumber (silink.properties ["out.item.id"])
local in_id = tonumber (silink.properties ["in.item.id"])
if out_id == si_id or in_id == si_id then
local in_flags = lutils:get_flags (in_id)
local out_flags = lutils:get_flags (out_id)
if out_id == si_id and in_flags.peer_id == out_id then
in_flags.peer_id = nil
elseif in_id == si_id and out_flags.peer_id == in_id then
out_flags.peer_id = nil
end
if cutils.parseBool (silink.properties["is.role.policy.link"]) then
lutils.clearPriorityMediaRoleLink(silink)
end
silink:remove ()
log:info (silink, "... link removed")
end
end
lutils:clear_flags (si_id)
end
SimpleEventHook {
name = "linking/linkable-removed",
interests = {
EventInterest {
Constraint { "event.type", "=", "session-item-removed" },
Constraint { "event.session-item.interface", "=", "linkable" },
},
},
execute = function (event)
local si = event:get_subject ()
local source = event:get_source ()
local om = source:call ("get-object-manager", "session-item")
unhandleLinkable (si, om)
end
}:register ()
function handleLinkables (source)
local om = source:call ("get-object-manager", "session-item")
for si in om:iterate { type = "SiLinkable" } do
local valid, si_props = checkLinkable (si, om)
if not valid then
goto skip_linkable
end
-- check if we need to link this node at all
local autoconnect = cutils.parseBool (si_props ["node.autoconnect"])
if not autoconnect then
log:debug (si, tostring (si_props ["node.name"]) .. " does not need to be autoconnected")
goto skip_linkable
end
-- push event to find target and link
source:call ("push-event", "select-target", si, nil)
::skip_linkable::
end
end
SimpleEventHook {
name = "linking/rescan",
interests = {
EventInterest {
Constraint { "event.type", "=", "rescan-for-linking" },
},
},
execute = function (event)
local source = event:get_source ()
local om = source:call ("get-object-manager", "session-item")
log:info ("rescanning...")
-- always unlink all filters that are smart and disabled
for si in om:iterate {
type = "SiLinkable",
Constraint { "node.link-group", "+" },
} do
local node = si:get_associated_proxy ("node")
local link_group = node.properties["node.link-group"]
local direction = cutils.getTargetDirection (si.properties)
if futils.is_filter_smart (direction, link_group) and
futils.is_filter_disabled (direction, link_group) then
unhandleLinkable (si, om)
end
end
handleLinkables (source)
end
}:register ()
SimpleEventHook {
name = "linking/rescan-trigger",
interests = {
-- on linkable added or removed, where linkable is adapter or plain node
EventInterest {
Constraint { "event.type", "c", "session-item-added", "session-item-removed" },
Constraint { "event.session-item.interface", "=", "linkable" },
},
-- on device Routes changed
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "c", "Route", "EnumRoute" },
},
-- on any "default" target changed
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "c", "default.audio.source",
"default.audio.sink", "default.video.source" },
},
},
execute = function (event)
local source = event:get_source ()
source:call ("schedule-rescan", "linking")
end
}:register ()
SimpleEventHook {
name = "linking/rescan-trigger-on-filters-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "filters" },
},
},
execute = function (event)
local source = event:get_source ()
source:call ("schedule-rescan", "linking")
end
}:register ()
function handleMoveSetting (enable)
if (not handles.move_hook) and (enable == true) then
handles.move_hook = SimpleEventHook {
name = "linking/rescan-trigger-on-target-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "c", "target.object", "target.node" },
},
},
execute = function (event)
local source = event:get_source ()
source:call ("schedule-rescan", "linking")
end
}
handles.move_hook:register()
elseif (handles.move_hook) and (enable == false) then
handles.move_hook:remove ()
handles.move_hook = nil
end
end
Settings.subscribe ("linking.allow-moving-streams", function ()
handleMoveSetting (Settings.get_boolean ("linking.allow-moving-streams"))
end)
handleMoveSetting (Settings.get_boolean ("linking.allow-moving-streams"))

View File

@@ -0,0 +1,28 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Provides the default metadata object
Script.async_activation = true
-- note that args is a WpSpaJson
local args = ...
args = args:parse(1)
local metadata_name = args["metadata.name"]
log = Log.open_topic ("s-metadata")
log:info ("creating metadata object: " .. metadata_name)
impl_metadata = ImplMetadata (metadata_name)
impl_metadata:activate (Features.ALL, function (m, e)
if e then
Script:finish_activation_with_error (
"failed to activate the ".. metadata_name .." metadata: " .. tostring (e))
else
Script:finish_activation ()
end
end)

View File

@@ -0,0 +1,74 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-monitors")
defaults = {}
defaults.node_properties = { -- Midi bridge node properties
["factory.name"] = "api.alsa.seq.bridge",
-- Name set for the node with ALSA MIDI ports
["node.name"] = "Midi-Bridge",
-- Set priorities so that it can be used as a fallback driver (see pipewire#3562)
["priority.session"] = "100",
["priority.driver"] = "1",
}
config = {}
config.monitoring = Core.test_feature ("monitor.alsa-midi.monitoring")
config.node_properties = Conf.get_section_as_properties (
"monitor.alsa-midi.properties", defaults.node_properties)
SND_PATH = "/dev/snd"
SEQ_NAME = "seq"
SND_SEQ_PATH = SND_PATH .. "/" .. SEQ_NAME
midi_node = nil
fm_plugin = nil
function CreateMidiNode ()
-- create the midi node
local node = Node("spa-node-factory", config.node_properties)
node:activate(Feature.Proxy.BOUND, function (n)
log:info ("activated Midi bridge")
end)
return node;
end
if GLib.access (SND_SEQ_PATH, "rw") then
midi_node = CreateMidiNode ()
elseif config.monitoring then
fm_plugin = Plugin.find("file-monitor-api")
end
-- Only monitor the MIDI device if file does not exist and plugin API is loaded
if midi_node == nil and fm_plugin ~= nil then
-- listen for changed events
fm_plugin:connect ("changed", function (o, file, old, evtype)
-- files attributes changed
if evtype == "attribute-changed" then
if file ~= SND_SEQ_PATH then
return
end
if midi_node == nil and GLib.access (SND_SEQ_PATH, "rw") then
midi_node = CreateMidiNode ()
fm_plugin:call ("remove-watch", SND_PATH)
end
end
-- directory is going to be unmounted
if evtype == "pre-unmount" then
fm_plugin:call ("remove-watch", SND_PATH)
end
end)
-- add watch
fm_plugin:call ("add-watch", SND_PATH, "m")
end

View File

@@ -0,0 +1,391 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-monitors")
config = {}
config.reserve_device = Core.test_feature ("monitor.alsa.reserve-device")
config.properties = Conf.get_section_as_properties ("monitor.alsa.properties")
config.rules = Conf.get_section_as_json ("monitor.alsa.rules", Json.Array {})
-- unique device/node name tables
device_names_table = nil
node_names_table = nil
function nonempty(str)
return str ~= "" and str or nil
end
function applyDefaultDeviceProperties (properties)
properties["api.alsa.use-acp"] = true
properties["api.acp.auto-port"] = false
properties["api.dbus.ReserveDevice1.Priority"] = -20
end
function createNode(parent, id, obj_type, factory, properties)
local dev_props = parent.properties
-- set the device id and spa factory name; REQUIRED, do not change
properties["device.id"] = parent["bound-id"]
properties["factory.name"] = factory
-- set the default pause-on-idle setting
properties["node.pause-on-idle"] = false
-- try to negotiate the max ammount of channels
if dev_props["api.alsa.use-acp"] ~= "true" then
properties["audio.channels"] = properties["audio.channels"] or "64"
end
local dev = properties["api.alsa.pcm.device"]
or properties["alsa.device"] or "0"
local subdev = properties["api.alsa.pcm.subdevice"]
or properties["alsa.subdevice"] or "0"
local stream = properties["api.alsa.pcm.stream"] or "unknown"
local profile = properties["device.profile.name"]
or (stream .. "." .. dev .. "." .. subdev)
local profile_desc = properties["device.profile.description"]
-- set priority
if not properties["priority.driver"] then
local priority = (dev == "0") and 1000 or 744
if stream == "capture" then
priority = priority + 1000
end
priority = priority - (tonumber(dev) * 16) - tonumber(subdev)
if profile:find("^pro%-") then
priority = priority + 500
elseif profile:find("^analog%-") then
priority = priority + 9
elseif profile:find("^iec958%-") then
priority = priority + 8
end
properties["priority.driver"] = priority
properties["priority.session"] = priority
end
-- ensure the node has a media class
if not properties["media.class"] then
if stream == "capture" then
properties["media.class"] = "Audio/Source"
else
properties["media.class"] = "Audio/Sink"
end
end
-- ensure the node has a name
if not properties["node.name"] then
local name =
(stream == "capture" and "alsa_input" or "alsa_output")
.. "." ..
(dev_props["device.name"]:gsub("^alsa_card%.(.+)", "%1") or
dev_props["device.name"] or
"unnamed-device")
.. "." ..
profile
-- sanitize name
name = name:gsub("([^%w_%-%.])", "_")
properties["node.name"] = name
log:info ("Creating node " .. name)
-- deduplicate nodes with the same name
for counter = 2, 99, 1 do
if node_names_table[properties["node.name"]] ~= true then
node_names_table[properties["node.name"]] = true
break
end
properties["node.name"] = name .. "." .. counter
log:info ("deduplicating node name -> " .. properties["node.name"])
end
end
-- and a nick
local nick = nonempty(properties["node.nick"])
or nonempty(properties["api.alsa.pcm.name"])
or nonempty(properties["alsa.name"])
or nonempty(profile_desc)
or dev_props["device.nick"]
if nick == "USB Audio" then
nick = dev_props["device.nick"]
end
-- also sanitize nick, replace ':' with ' '
properties["node.nick"] = nick:gsub("(:)", " ")
-- ensure the node has a description
if not properties["node.description"] then
local desc = nonempty(dev_props["device.description"]) or "unknown"
local name = nonempty(properties["api.alsa.pcm.name"]) or
nonempty(properties["api.alsa.pcm.id"]) or dev
if profile_desc then
desc = desc .. " " .. profile_desc
elseif subdev ~= "0" then
desc = desc .. " (" .. name .. " " .. subdev .. ")"
elseif dev ~= "0" then
desc = desc .. " (" .. name .. ")"
end
-- also sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
end
-- add api.alsa.card.* properties for rule matching purposes
for k, v in pairs(dev_props) do
if k:find("^api%.alsa%.card%..*") then
properties[k] = v
end
end
-- add cpu.vm.name for rule matching purposes
local vm_type = Core.get_vm_type()
if nonempty(vm_type) then
properties["cpu.vm.name"] = vm_type
end
-- apply properties from rules defined in JSON .conf file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
if cutils.parseBool (properties ["node.disabled"]) then
log:notice ("ALSA node " .. properties["node.name"] .. " disabled")
node_names_table [properties ["node.name"]] = nil
return
end
-- create the node
local node = Node("adapter", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
end
function createDevice(parent, id, factory, properties)
local device = SpaDevice(factory, properties)
if device then
device:connect("create-object", createNode)
device:connect("object-removed", function (parent, id)
local node = parent:get_managed_object(id)
if not node then
return
end
local node_name = node.properties["node.name"]
log:info ("Removing node " .. node_name)
node_names_table[node_name] = nil
end)
device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
parent:store_managed_object(id, device)
else
log:warning ("Failed to create '" .. factory .. "' device")
end
end
function prepareDevice(parent, id, obj_type, factory, properties)
-- ensure the device has an appropriate name
local name = "alsa_card." ..
(properties["device.name"] or
properties["device.bus-id"] or
properties["device.bus-path"] or
tostring(id)):gsub("([^%w_%-%.])", "_")
properties["device.name"] = name
-- deduplicate devices with the same name
for counter = 2, 99, 1 do
if device_names_table[properties["device.name"]] ~= true then
device_names_table[properties["device.name"]] = true
break
end
properties["device.name"] = name .. "." .. counter
end
-- ensure the device has a description
if not properties["device.description"] then
local d = nil
local f = properties["device.form-factor"]
local c = properties["device.class"]
local n = properties["api.alsa.card.name"]
if n == "Loopback" then
d = I18n.gettext("Loopback")
elseif f == "internal" then
d = I18n.gettext("Built-in Audio")
elseif c == "modem" then
d = I18n.gettext("Modem")
end
d = d or properties["device.product.name"]
or properties["api.alsa.card.name"]
or properties["alsa.card_name"]
or "Unknown device"
properties["device.description"] = d
end
-- ensure the device has a nick
properties["device.nick"] =
properties["device.nick"] or
properties["api.alsa.card.name"] or
properties["alsa.card_name"]
-- set the icon name
if not properties["device.icon-name"] then
local icon = nil
local icon_map = {
-- form factor -> icon
["microphone"] = "audio-input-microphone",
["webcam"] = "camera-web",
["handset"] = "phone",
["portable"] = "multimedia-player",
["tv"] = "video-display",
["headset"] = "audio-headset",
["headphone"] = "audio-headphones",
["speaker"] = "audio-speakers",
["hands-free"] = "audio-handsfree",
}
local f = properties["device.form-factor"]
local c = properties["device.class"]
local b = properties["device.bus"]
icon = icon_map[f] or ((c == "modem") and "modem") or "audio-card"
properties["device.icon-name"] = icon .. "-analog" .. (b and ("-" .. b) or "")
end
-- apply properties from rules defined in JSON .conf file
applyDefaultDeviceProperties (properties)
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
if cutils.parseBool (properties ["device.disabled"]) then
log:notice ("ALSA card/device " .. properties ["device.name"] .. " disabled")
device_names_table [properties ["device.name"]] = nil
return
end
-- override the device factory to use ACP
if cutils.parseBool (properties ["api.alsa.use-acp"]) then
log:info("Enabling the use of ACP on " .. properties["device.name"])
factory = "api.alsa.acp.device"
end
-- use device reservation, if available
if rd_plugin and properties["api.alsa.card"] then
local rd_name = "Audio" .. properties["api.alsa.card"]
local rd = rd_plugin:call("create-reservation",
rd_name,
cutils.get_application_name (),
properties["device.name"],
properties["api.dbus.ReserveDevice1.Priority"]);
properties["api.dbus.ReserveDevice1"] = rd_name
-- unlike pipewire-media-session, this logic here keeps the device
-- acquired at all times and destroys it if someone else acquires
rd:connect("notify::state", function (rd, pspec)
local state = rd["state"]
if state == "acquired" then
-- create the device
createDevice(parent, id, factory, properties)
elseif state == "available" then
-- attempt to acquire again
rd:call("acquire")
elseif state == "busy" then
-- destroy the device
parent:store_managed_object(id, nil)
end
end)
rd:connect("release-requested", function (rd)
log:info("release requested")
parent:store_managed_object(id, nil)
rd:call("release")
end)
rd:call("acquire")
else
-- create the device
createDevice(parent, id, factory, properties)
end
end
function createMonitor ()
local m = SpaDevice("api.alsa.enum.udev", config.properties)
if m == nil then
log:notice("PipeWire's ALSA SPA plugin is missing or broken. " ..
"Sound cards will not be supported")
return nil
end
-- handle create-object to prepare device
m:connect("create-object", prepareDevice)
-- handle object-removed to destroy device reservations and recycle device name
m:connect("object-removed", function (parent, id)
local device = parent:get_managed_object(id)
if not device then
return
end
if rd_plugin then
local rd_name = device.properties["api.dbus.ReserveDevice1"]
if rd_name then
rd_plugin:call("destroy-reservation", rd_name)
end
end
device_names_table[device.properties["device.name"]] = nil
for managed_node in device:iterate_managed_objects() do
node_names_table[managed_node.properties["node.name"]] = nil
end
end)
-- reset the name tables to make sure names are recycled
device_names_table = {}
node_names_table = {}
-- activate monitor
log:info("Activating ALSA monitor")
m:activate(Feature.SpaDevice.ENABLED)
return m
end
-- if the reserve-device plugin is enabled, at the point of script execution
-- it is expected to be connected. if it is not, assume the d-bus connection
-- has failed and continue without it
if config.reserve_device then
rd_plugin = Plugin.find("reserve-device")
end
if rd_plugin and rd_plugin:call("get-dbus")["state"] ~= "connected" then
log:notice("reserve-device plugin is not connected to D-Bus, "
.. "disabling device reservation")
rd_plugin = nil
end
-- handle rd_plugin state changes to destroy and re-create the ALSA monitor in
-- case D-Bus service is restarted
if rd_plugin then
local dbus = rd_plugin:call("get-dbus")
dbus:connect("notify::state", function (b, pspec)
local state = b["state"]
log:info ("rd-plugin state changed to " .. state)
if state == "connected" then
log:info ("Creating ALSA monitor")
monitor = createMonitor()
elseif state == "closed" then
log:info ("Destroying ALSA monitor")
monitor = nil
end
end)
end
-- create the monitor
monitor = createMonitor()

View File

@@ -0,0 +1,162 @@
-- WirePlumber
--
-- Copyright © 2022 Pauli Virtanen
-- @author Pauli Virtanen
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-monitors")
defaults = {}
defaults.servers = { "bluez_midi.server" }
config = {}
config.seat_monitoring = Core.test_feature ("monitor.bluez.seat-monitoring")
config.properties = Conf.get_section_as_properties ("monitor.bluez-midi.properties")
config.servers = Conf.get_section_as_array ("monitor.bluez-midi.servers", defaults.servers)
config.rules = Conf.get_section_as_json ("monitor.bluez-midi.rules", Json.Array {})
-- unique device/node name tables
node_names_table = nil
id_to_name_table = nil
function setLatencyOffset(node, offset_msec)
if not offset_msec then
return
end
local props = { "Spa:Pod:Object:Param:Props", "Props" }
props.latencyOffsetNsec = tonumber(offset_msec) * 1000000
local param = Pod.Object(props)
log:debug(param, "setting latency offset on " .. tostring(node))
node:set_param("Props", param)
end
function createNode(parent, id, type, factory, properties)
properties["factory.name"] = factory
-- set the node description
local desc = properties["node.description"]
-- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
-- set the node name
local name =
"bluez_midi." .. properties["api.bluez5.address"]
-- sanitize name
name = name:gsub("([^%w_%-%.])", "_")
-- deduplicate nodes with the same name
properties["node.name"] = name
for counter = 2, 99, 1 do
if node_names_table[properties["node.name"]] ~= true then
node_names_table[properties["node.name"]] = true
break
end
properties["node.name"] = name .. "." .. counter
end
properties["api.glib.mainloop"] = "true"
-- apply properties from the rules in the configuration file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
local latency_offset = properties["node.latency-offset-msec"]
properties["node.latency-offset-msec"] = nil
-- create the node
-- it doesn't necessarily need to be a local node,
-- the other Bluetooth parts run in the local process,
-- so it's consistent to have also this here
local node = LocalNode("spa-node-factory", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
id_to_name_table[id] = properties["node.name"]
setLatencyOffset(node, latency_offset)
end
function createMonitor()
local monitor_props = {}
for k, v in pairs(config.properties or {}) do
monitor_props[k] = v
end
monitor_props["api.glib.mainloop"] = "true"
local monitor = SpaDevice("api.bluez5.midi.enum", monitor_props)
if monitor then
monitor:connect("create-object", createNode)
monitor:connect("object-removed", function (parent, id)
node_names_table[id_to_name_table[id]] = nil
id_to_name_table[id] = nil
end)
else
log:notice("PipeWire's BlueZ MIDI SPA missing or broken. Bluetooth not supported.")
return nil
end
-- reset the name tables to make sure names are recycled
node_names_table = {}
id_to_name_table = {}
monitor:activate(Feature.SpaDevice.ENABLED)
return monitor
end
function createServers()
local servers = {}
local i = 1
for k, v in pairs(config.servers) do
local node_props = {
["node.name"] = v,
["node.description"] = string.format(I18n.gettext("BLE MIDI %d"), i),
["api.bluez5.role"] = "server",
["factory.name"] = "api.bluez5.midi.node",
["api.glib.mainloop"] = "true",
}
node_props = JsonUtils.match_rules_update_properties (config.rules, node_props)
local latency_offset = node_props["node.latency-offset-msec"]
node_props["node.latency-offset-msec"] = nil
local node = LocalNode("spa-node-factory", node_props)
if node then
node:activate(Feature.Proxy.BOUND)
table.insert(servers, node)
setLatencyOffset(node, latency_offset)
else
log:notice("Failed to create BLE MIDI server.")
end
i = i + 1
end
return servers
end
if config.seat_monitoring then
logind_plugin = Plugin.find("logind")
end
if logind_plugin then
-- if logind support is enabled, activate
-- the monitor only when the seat is active
function startStopMonitor(seat_state)
log:info(logind_plugin, "Seat state changed: " .. seat_state)
if seat_state == "active" then
monitor = createMonitor()
servers = createServers()
elseif monitor then
monitor:deactivate(Feature.SpaDevice.ENABLED)
monitor = nil
servers = nil
end
end
logind_plugin:connect("state-changed", function(p, s) startStopMonitor(s) end)
startStopMonitor(logind_plugin:call("get-state"))
else
monitor = createMonitor()
servers = createServers()
end

View File

@@ -0,0 +1,517 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
COMBINE_OFFSET = 64
LOOPBACK_SOURCE_ID = 128
DEVICE_SOURCE_ID = 0
cutils = require ("common-utils")
log = Log.open_topic ("s-monitors")
config = {}
config.seat_monitoring = Core.test_feature ("monitor.bluez.seat-monitoring")
config.properties = Conf.get_section_as_properties ("monitor.bluez.properties")
config.rules = Conf.get_section_as_json ("monitor.bluez.rules", Json.Array {})
-- This is not a setting, it must always be enabled
config.properties["api.bluez5.connection-info"] = true
devices_om = ObjectManager {
Interest {
type = "device",
}
}
nodes_om = ObjectManager {
Interest {
type = "node",
Constraint { "node.name", "#", "*.bluez_*put*"},
Constraint { "device.id", "+" },
}
}
function setOffloadActive(device, value)
local pod = Pod.Object {
"Spa:Pod:Object:Param:Props", "Props", bluetoothOffloadActive = value
}
device:set_params("Props", pod)
end
nodes_om:connect("object-added", function(_, node)
node:connect("state-changed", function(node, old_state, cur_state)
local interest = Interest {
type = "device",
Constraint { "object.id", "=", node.properties["device.id"]}
}
for d in devices_om:iterate (interest) do
if cur_state == "running" then
setOffloadActive(d, true)
else
setOffloadActive(d, false)
end
end
end)
end)
function createOffloadScoNode(parent, id, type, factory, properties)
local dev_props = parent.properties
local args = {
["audio.channels"] = 1,
["audio.position"] = "[MONO]",
}
local desc =
dev_props["device.description"]
or dev_props["device.name"]
or dev_props["device.nick"]
or dev_props["device.alias"]
or "bluetooth-device"
-- sanitize description, replace ':' with ' '
args["node.description"] = desc:gsub("(:)", " ")
if factory:find("sink") then
local capture_args = {
["device.id"] = parent["bound-id"],
["media.class"] = "Audio/Sink",
["node.pause-on-idle"] = false,
}
for k, v in pairs(properties) do
capture_args[k] = v
end
local name = "bluez_output" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id)
args["node.name"] = name:gsub("([^%w_%-%.])", "_")
args["capture.props"] = Json.Object(capture_args)
args["playback.props"] = Json.Object {
["node.passive"] = true,
["node.pause-on-idle"] = false,
}
elseif factory:find("source") then
local playback_args = {
["device.id"] = parent["bound-id"],
["media.class"] = "Audio/Source",
["node.pause-on-idle"] = false,
}
for k, v in pairs(properties) do
playback_args[k] = v
end
local name = "bluez_input" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id)
args["node.name"] = name:gsub("([^%w_%-%.])", "_")
args["capture.props"] = Json.Object {
["node.passive"] = true,
["node.pause-on-idle"] = false,
}
args["playback.props"] = Json.Object(playback_args)
else
log:warning(parent, "Unsupported factory: " .. factory)
return
end
-- Transform 'args' to a json object here
local args_json = Json.Object(args)
-- and get the final JSON as a string from the json object
local args_string = args_json:get_data()
local loopback_properties = {}
local loopback = LocalModule("libpipewire-module-loopback", args_string, loopback_properties)
parent:store_managed_object(id, loopback)
end
device_set_nodes_om = ObjectManager {
Interest {
type = "node",
Constraint { "api.bluez5.set.leader", "+", type = "pw" },
}
}
device_set_nodes_om:connect ("object-added", function(_, node)
-- Connect ObjectConfig events to the right node
if not monitor then
return
end
local interest = Interest {
type = "device",
Constraint { "object.id", "=", node.properties["device.id"] }
}
log:info("Device set node found: " .. tostring (node["bound-id"]))
for device in devices_om:iterate (interest) do
local device_id = device.properties["api.bluez5.id"]
if not device_id then
goto next_device
end
local spa_device = monitor:get_managed_object (tonumber (device_id))
if not spa_device then
goto next_device
end
local id = node.properties["card.profile.device"]
if id ~= nil then
log:info(".. assign to device: " .. tostring (device["bound-id"]) .. " node " .. tostring (id))
spa_device:store_managed_object (id, node)
-- set routes again to update volumes etc.
for route in device:iterate_params ("Route") do
device:set_param ("Route", route)
end
end
::next_device::
end
end)
function createSetNode(parent, id, type, factory, properties)
local args = {}
local target_class
local stream_class
local rules = {}
local members_json = Json.Raw (properties["api.bluez5.set.members"])
local channels_json = Json.Raw (properties["api.bluez5.set.channels"])
local members = members_json:parse ()
local channels = channels_json:parse ()
if properties["media.class"] == "Audio/Sink" then
args["combine.mode"] = "sink"
target_class = "Audio/Sink/Internal"
stream_class = "Stream/Output/Audio/Internal"
else
args["combine.mode"] = "source"
target_class = "Audio/Source/Internal"
stream_class = "Stream/Input/Audio/Internal"
end
log:info("Device set: " .. properties["node.name"])
for _, member in pairs(members) do
log:info("Device set member:" .. member["object.path"])
table.insert(rules,
Json.Object {
["matches"] = Json.Array {
Json.Object {
["object.path"] = member["object.path"],
["media.class"] = target_class,
},
},
["actions"] = Json.Object {
["create-stream"] = Json.Object {
["media.class"] = stream_class,
["audio.position"] = Json.Array (member["channels"]),
}
},
}
)
end
properties["node.virtual"] = false
properties["device.api"] = "bluez5"
properties["api.bluez5.set.members"] = nil
properties["api.bluez5.set.channels"] = nil
properties["api.bluez5.set.leader"] = true
properties["audio.position"] = Json.Array (channels)
args["combine.props"] = Json.Object (properties)
args["stream.props"] = Json.Object {}
args["stream.rules"] = Json.Array (rules)
local args_json = Json.Object(args)
local args_string = args_json:get_data()
local combine_properties = {}
log:info("Device set node: " .. args_string)
return LocalModule("libpipewire-module-combine-stream", args_string, combine_properties)
end
function createNode(parent, id, type, factory, properties)
local dev_props = parent.properties
local parent_id = parent["bound-id"]
if cutils.parseBool (config.properties ["bluez5.hw-offload-sco"]) and factory:find("sco") then
createOffloadScoNode(parent, id, type, factory, properties)
return
end
-- set the device id and spa factory name; REQUIRED, do not change
properties["device.id"] = parent_id
properties["factory.name"] = factory
-- set the default pause-on-idle setting
properties["node.pause-on-idle"] = false
-- set the node description
local desc =
dev_props["device.description"]
or dev_props["device.name"]
or dev_props["device.nick"]
or dev_props["device.alias"]
or "bluetooth-device"
-- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
local name_prefix = ((factory:find("sink") and "bluez_output") or
(factory:find("source") and "bluez_input" or factory))
-- hide the source node because we use the loopback source instead
if parent:get_managed_object (LOOPBACK_SOURCE_ID) ~= nil and
(factory == "api.bluez5.sco.source" or
(factory == "api.bluez5.a2dp.source" and cutils.parseBool (properties["api.bluez5.a2dp-duplex"]))) then
properties["bluez5.loopback-target"] = true
properties["api.bluez5.internal"] = true
-- add 'internal' to name prefix to not be confused with loopback node
name_prefix = name_prefix .. "_internal"
end
-- set the node name
local name = name_prefix .. "." ..
(properties["api.bluez5.address"] or dev_props["device.name"]) .. "." ..
tostring(id)
-- sanitize name
properties["node.name"] = name:gsub("([^%w_%-%.])", "_")
-- set priority
if not properties["priority.driver"] then
local priority = factory:find("source") and 2010 or 1010
properties["priority.driver"] = priority
properties["priority.session"] = priority
end
-- autoconnect if it's a stream
if properties["api.bluez5.profile"] == "headset-audio-gateway" or
properties["api.bluez5.profile"] == "bap-sink" or
factory:find("a2dp.source") or factory:find("media.source") then
properties["node.autoconnect"] = true
end
-- apply properties from the rules in the configuration file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
-- create the node; bluez requires "local" nodes, i.e. ones that run in
-- the same process as the spa device, for several reasons
if properties["api.bluez5.set.leader"] then
local combine = createSetNode(parent, id, type, factory, properties)
parent:store_managed_object(id + COMBINE_OFFSET, combine)
else
properties["bluez5.loopback"] = false
local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
end
end
function removeNode(parent, id)
-- Clear also the device set module, if any
parent:store_managed_object(id + COMBINE_OFFSET, nil)
end
function createDevice(parent, id, type, factory, properties)
local device = parent:get_managed_object(id)
if not device then
-- ensure a proper device name
local name =
(properties["device.name"] or
properties["api.bluez5.address"] or
properties["device.description"] or
tostring(id)):gsub("([^%w_%-%.])", "_")
if not name:find("^bluez_card%.", 1) then
name = "bluez_card." .. name
end
properties["device.name"] = name
-- set the icon name
if not properties["device.icon-name"] then
local icon = nil
local icon_map = {
-- form factor -> icon
["microphone"] = "audio-input-microphone",
["webcam"] = "camera-web",
["handset"] = "phone",
["portable"] = "multimedia-player",
["tv"] = "video-display",
["headset"] = "audio-headset",
["headphone"] = "audio-headphones",
["speaker"] = "audio-speakers",
["hands-free"] = "audio-handsfree",
}
local f = properties["device.form-factor"]
local b = properties["device.bus"]
icon = icon_map[f] or "audio-card"
properties["device.icon-name"] = icon .. (b and ("-" .. b) or "")
end
-- initial profile is to be set by policy-device-profile.lua, not spa-bluez5
properties["bluez5.profile"] = "off"
properties["api.bluez5.id"] = id
-- apply properties from the rules in the configuration file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
-- create the device
device = SpaDevice(factory, properties)
if device then
device:connect("create-object", createNode)
device:connect("object-removed", removeNode)
parent:store_managed_object(id, device)
else
log:warning ("Failed to create '" .. factory .. "' device")
return
end
end
log:info(parent, string.format("%d, %s (%s): %s",
id, properties["device.description"],
properties["api.bluez5.address"], properties["api.bluez5.connection"]))
-- activate the device after the bluez profiles are connected
if properties["api.bluez5.connection"] == "connected" then
device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
else
device:deactivate(Features.ALL)
end
end
function createMonitor()
local monitor = SpaDevice("api.bluez5.enum.dbus", config.properties)
if monitor then
monitor:connect("create-object", createDevice)
else
log:notice("PipeWire's BlueZ SPA plugin is missing or broken. " ..
"Bluetooth devices will not be supported.")
return nil
end
monitor:activate(Feature.SpaDevice.ENABLED)
return monitor
end
function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id)
local args = Json.Object {
["capture.props"] = Json.Object {
["node.name"] = string.format ("bluez_capture_internal.%s", dev_name),
["media.class"] = "Stream/Input/Audio/Internal",
["node.description"] =
string.format ("Bluetooth internal capture stream for %s", dec_desc),
["audio.channels"] = 1,
["audio.position"] = "[MONO]",
["bluez5.loopback"] = true,
["stream.dont-remix"] = true,
["node.passive"] = true,
["node.dont-fallback"] = true,
["node.linger"] = true
},
["playback.props"] = Json.Object {
["node.name"] = string.format ("bluez_input.%s", dev_name),
["node.description"] = string.format ("%s", dec_desc),
["audio.position"] = "[MONO]",
["media.class"] = "Audio/Source",
["device.id"] = dev_id,
["card.profile.device"] = DEVICE_SOURCE_ID,
["priority.driver"] = 2010,
["priority.session"] = 2010,
["bluez5.loopback"] = true,
["filter.smart"] = true,
["filter.smart.target"] = Json.Object {
["bluez5.loopback-target"] = true,
["bluez5.loopback"] = false,
["device.id"] = dev_id
}
}
}
return LocalModule("libpipewire-module-loopback", args:get_data(), {})
end
function checkProfiles (dev)
local device_id = dev["bound-id"]
local props = dev.properties
-- Don't create loopback source device if autoswitch is disabled
if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then
return
end
-- Get the associated BT SpaDevice
local internal_id = tostring (props["api.bluez5.id"])
local spa_device = monitor:get_managed_object (internal_id)
if spa_device == nil then
return
end
-- Ignore devices that don't support both A2DP sink and HSP/HFP profiles
local has_a2dpsink_profile = false
local has_headset_profile = false
for p in dev:iterate_params("EnumProfile") do
local profile = cutils.parseParam (p, "EnumProfile")
if profile.name:find ("a2dp") and profile.name:find ("sink") then
has_a2dpsink_profile = true
elseif profile.name:find ("headset") then
has_headset_profile = true
end
end
if not has_a2dpsink_profile or not has_headset_profile then
return
end
-- Create the loopback device if never created before
local loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID)
if loopback == nil then
local dev_name = props["api.bluez5.address"] or props["device.name"]
local dec_desc = props["device.description"] or props["device.name"]
or props["device.nick"] or props["device.alias"] or "bluetooth-device"
-- sanitize description, replace ':' with ' '
dec_desc = dec_desc:gsub("(:)", " ")
loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id)
spa_device:store_managed_object(LOOPBACK_SOURCE_ID, loopback)
end
end
function onDeviceParamsChanged (dev, param_name)
if param_name == "EnumProfile" then
checkProfiles (dev)
end
end
devices_om:connect("object-added", function(_, dev)
-- Ignore all devices that are not BT devices
if dev.properties["device.api"] ~= "bluez5" then
return
end
-- check available profiles
dev:connect ("params-changed", onDeviceParamsChanged)
checkProfiles (dev)
end)
if config.seat_monitoring then
logind_plugin = Plugin.find("logind")
end
if logind_plugin then
-- if logind support is enabled, activate
-- the monitor only when the seat is active
function startStopMonitor(seat_state)
log:info(logind_plugin, "Seat state changed: " .. seat_state)
if seat_state == "active" then
monitor = createMonitor()
elseif monitor then
monitor:deactivate(Feature.SpaDevice.ENABLED)
monitor = nil
end
end
logind_plugin:connect("state-changed", function(p, s) startStopMonitor(s) end)
startStopMonitor(logind_plugin:call("get-state"))
else
monitor = createMonitor()
end
nodes_om:activate()
devices_om:activate()
device_set_nodes_om:activate()

View File

@@ -0,0 +1,61 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-libcamera")
config = {}
config.rules = Conf.get_section_as_json ("monitor.libcamera.rules", Json.Array {})
function createLibcamNode (parent, id, type, factory, properties)
local registered = mutils:register_cam_node (parent, id, factory, properties)
if not registered then
source = source or Plugin.find ("standard-event-source")
local e = source:call ("create-event", "create-libcamera-device-node",
parent, nil)
e:set_data ("factory", factory)
e:set_data ("node-properties", properties)
e:set_data ("node-sub-id", id)
EventDispatcher.push_event (e)
end
end
SimpleEventHook {
name = "monitor/libcamera/create-device",
after = "monitor/libcamera/name-device",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-libcamera-device" },
},
},
execute = function(event)
local properties = event:get_data ("device-properties")
local factory = event:get_data ("factory")
local parent = event:get_subject ()
local id = event:get_data ("device-sub-id")
-- apply properties from rules defined in JSON .conf file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
if cutils.parseBool (properties ["device.disabled"]) then
log:notice ("libcam device " .. properties["device.name"] .. " disabled")
return
end
local device = SpaDevice (factory, properties)
if device then
device:connect ("create-object", createLibcamNode)
device:activate (Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
parent:store_managed_object (id, device)
else
log:warning ("Failed to create '" .. factory .. "' device")
end
end
}:register ()

View File

@@ -0,0 +1,41 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-libcamera")
config = {}
config.rules = Conf.get_section_as_json ("monitor.libcamera.rules", Json.Array {})
SimpleEventHook {
name = "monitor/libcamera/create-node",
after = "monitor/libcamera/name-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-libcamera-device-node" },
},
},
execute = function(event)
local properties = event:get_data ("node-properties")
local parent = event:get_subject ()
local id = event:get_data ("node-sub-id")
-- apply properties from rules defined in JSON .conf file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
if cutils.parseBool (properties["node.disabled"]) then
log:notice ("libcam node" .. properties ["node.name"] .. " disabled")
return
end
-- create the node
local node = Node ("spa-node-factory", properties)
node:activate (Feature.Proxy.BOUND)
parent:store_managed_object (id, node)
end
}:register ()

View File

@@ -0,0 +1,32 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-monitors-libcamera")
config = {}
config.properties = Conf.get_section_as_properties ("monitor.libcamera.properties")
function createCamDevice (parent, id, type, factory, properties)
source = source or Plugin.find ("standard-event-source")
local e = source:call ("create-event", "create-libcamera-device", parent, nil)
e:set_data ("device-properties", properties)
e:set_data ("factory", factory)
e:set_data ("device-sub-id", id)
EventDispatcher.push_event (e)
end
monitor = SpaDevice ("api.libcamera.enum.manager", config.properties)
if monitor then
monitor:connect ("create-object", createCamDevice)
monitor:activate (Feature.SpaDevice.ENABLED)
else
log:notice ("PipeWire's libcamera SPA plugin is missing or broken. " ..
"Some camera types may not be supported.")
end

View File

@@ -0,0 +1,49 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-libcamera")
SimpleEventHook {
name = "monitor/libcamera/name-device",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-libcamera-device" },
},
},
execute = function(event)
local parent = event:get_subject ()
local properties = event:get_data ("device-properties")
local id = event:get_data ("device-sub-id")
local name = "libcamera_device." ..
(properties["device.name"] or
properties["device.bus-id"] or
properties["device.bus-path"] or
tostring (id)):gsub ("([^%w_%-%.])", "_")
properties["device.name"] = name
-- deduplicate devices with the same name
for counter = 2, 99, 1 do
if mutils.find_duplicate (parent, id, "device.name", properties["node.name"]) then
properties["device.name"] = name .. "." .. counter
else
break
end
end
-- ensure the device has a description
properties["device.description"] =
properties["device.description"]
or properties["device.product.name"]
or "Unknown device"
event:set_data ("device-properties", properties)
end
}:register ()

View File

@@ -0,0 +1,90 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-libcamera")
SimpleEventHook {
name = "monitor/libcamera/name-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-libcamera-device-node" },
},
},
execute = function(event)
local properties = event:get_data ("node-properties")
local parent = event:get_subject ()
local dev_props = parent.properties
local factory = event:get_data ("factory")
local id = event:get_data ("node-sub-id")
local location = properties ["api.libcamera.location"]
-- set the device id and spa factory name; REQUIRED, do not change
properties ["device.id"] = parent ["bound-id"]
properties ["factory.name"] = factory
-- set the default pause-on-idle setting
properties ["node.pause-on-idle"] = false
-- set the node name
local name =
(factory:find ("sink") and "libcamera_output") or
(factory:find ("source") and "libcamera_input" or factory)
.. "." ..
(dev_props ["device.name"]:gsub ("^libcamera_device%.(.+)", "%1") or
dev_props ["device.name"] or
dev_props ["device.nick"] or
dev_props ["device.alias"] or
"libcamera-device")
-- sanitize name
name = name:gsub ("([^%w_%-%.])", "_")
properties ["node.name"] = name
-- deduplicate nodes with the same name
for counter = 2, 99, 1 do
if mutils.find_duplicate (parent, id, "node.name", properties ["node.name"]) then
properties ["node.name"] = name .. "." .. counter
else
break
end
end
-- set the node description
local desc = dev_props ["device.description"] or "libcamera-device"
if location == "front" then
desc = I18n.gettext ("Built-in Front Camera")
elseif location == "back" then
desc = I18n.gettext ("Built-in Back Camera")
end
-- sanitize description, replace ':' with ' '
properties ["node.description"] = desc:gsub ("(:)", " ")
-- set the node nick
local nick = properties ["node.nick"] or
dev_props ["device.product.name"] or
dev_props ["device.description"] or
dev_props ["device.nick"]
properties ["node.nick"] = nick:gsub ("(:)", " ")
-- set priority
if not properties ["priority.session"] then
local priority = 700
if location == "external" then
priority = priority + 150
elseif location == "front" then
priority = priority + 100
elseif location == "back" then
priority = priority + 50
end
properties ["priority.session"] = priority
end
event:set_data ("node-properties", properties)
end
}:register ()

View File

@@ -0,0 +1,61 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-v4l2")
config = {}
config.rules = Conf.get_section_as_json ("monitor.v4l2.rules", Json.Array {})
function createV4l2camNode (parent, id, type, factory, properties)
local registered = mutils:register_cam_node (parent, id, factory, properties)
if not registered then
source = source or Plugin.find ("standard-event-source")
local e = source:call ("create-event", "create-v4l2-device-node",
parent, nil)
e:set_data ("factory", factory)
e:set_data ("node-properties", properties)
e:set_data ("node-sub-id", id)
EventDispatcher.push_event (e)
end
end
SimpleEventHook {
name = "monitor/v4l2/create-device",
after = "monitor/v4l2/name-device",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-v4l2-device" },
},
},
execute = function(event)
local properties = event:get_data ("device-properties")
local factory = event:get_data ("factory")
local parent = event:get_subject ()
local id = event:get_data ("device-sub-id")
-- apply properties from rules defined in JSON .conf file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
if cutils.parseBool (properties ["device.disabled"]) then
log:notice ("V4L2 device " .. properties["device.name"] .. " disabled")
return
end
local device = SpaDevice (factory, properties)
if device then
device:connect ("create-object", createV4l2camNode)
device:activate (Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
parent:store_managed_object (id, device)
else
log:warning ("Failed to create '" .. factory .. "' device")
end
end
}:register ()

View File

@@ -0,0 +1,42 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-v4l2")
config = {}
config.rules = Conf.get_section_as_json ("monitor.v4l2.rules", Json.Array {})
SimpleEventHook {
name = "monitor/v4l2/create-node",
after = "monitor/v4l2/name-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-v4l2-device-node" },
},
},
execute = function(event)
local properties = event:get_data ("node-properties")
local parent = event:get_subject ()
local id = event:get_data ("node-sub-id")
local factory = event:get_data ("factory")
-- apply properties from rules defined in JSON .conf file
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
if cutils.parseBool (properties ["node.disabled"]) then
log:notice ("V4L2 node" .. properties ["node.name"] .. " disabled")
return
end
-- create the node
local node = Node ("spa-node-factory", properties)
node:activate (Feature.Proxy.BOUND)
parent:store_managed_object (id, node)
end
}:register ()

View File

@@ -0,0 +1,32 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-monitors-v4l2")
config = {}
config.properties = Conf.get_section_as_properties ("monitor.v4l2.properties")
function createCamDevice (parent, id, type, factory, properties)
source = source or Plugin.find ("standard-event-source")
local e = source:call ("create-event", "create-v4l2-device", parent, nil)
e:set_data ("device-properties", properties)
e:set_data ("factory", factory)
e:set_data ("device-sub-id", id)
EventDispatcher.push_event (e)
end
monitor = SpaDevice ("api.v4l2.enum.udev", config.properties)
if monitor then
monitor:connect ("create-object", createCamDevice)
monitor:activate (Feature.SpaDevice.ENABLED)
else
log:notice ("PipeWire's V4L2 SPA plugin is missing or broken. " ..
"Some camera types may not be supported.")
end

View File

@@ -0,0 +1,49 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-v4l2")
SimpleEventHook {
name = "monitor/v4l2/name-device",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-v4l2-device" },
},
},
execute = function(event)
local properties = event:get_data ("device-properties")
local parent = event:get_subject ()
local id = event:get_data ("device-sub-id")
local name = "v4l2_device." ..
(properties["device.name"] or
properties["device.bus-id"] or
properties["device.bus-path"] or
tostring (id)):gsub ("([^%w_%-%.])", "_")
properties["device.name"] = name
-- deduplicate devices with the same name
for counter = 2, 99, 1 do
if mutils.find_duplicate (parent, id, "device.name", properties["node.name"]) then
properties["device.name"] = name .. "." .. counter
else
break
end
end
-- ensure the device has a description
properties["device.description"] =
properties["device.description"]
or properties["device.product.name"]
or "Unknown device"
event:set_data ("device-properties", properties)
end
}:register ()

View File

@@ -0,0 +1,80 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
--
-- SPDX-License-Identifier: MIT
mutils = require ("monitor-utils")
log = Log.open_topic ("s-monitors-v4l2")
SimpleEventHook {
name = "monitor/v4l2/name-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-v4l2-device-node" },
},
},
execute = function(event)
local properties = event:get_data ("node-properties")
local parent = event:get_subject ()
local dev_props = parent.properties
local factory = event:get_data ("factory")
local id = event:get_data ("node-sub-id")
-- set the device id and spa factory name; REQUIRED, do not change
properties["device.id"] = parent["bound-id"]
properties["factory.name"] = factory
-- set the default pause-on-idle setting
properties["node.pause-on-idle"] = false
-- set the node name
local name =
(factory:find ("sink") and "v4l2_output") or
(factory:find ("source") and "v4l2_input" or factory)
.. "." ..
(dev_props["device.name"]:gsub ("^v4l2_device%.(.+)", "%1") or
dev_props["device.name"] or
dev_props["device.nick"] or
dev_props["device.alias"] or
"v4l2-device")
-- sanitize name
name = name:gsub ("([^%w_%-%.])", "_")
properties["node.name"] = name
-- deduplicate nodes with the same name
for counter = 2, 99, 1 do
if mutils.find_duplicate (parent, id, "node.name", properties["node.name"]) then
properties["node.name"] = name .. "." .. counter
else
break
end
end
-- set the node description
local desc = dev_props["device.description"] or "v4l2-device"
desc = desc .. " (V4L2)"
-- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub ("(:)", " ")
-- set the node nick
local nick = properties["node.nick"] or
dev_props["device.product.name"] or
dev_props["api.v4l2.cap.card"] or
dev_props["device.description"] or
dev_props["device.nick"]
properties["node.nick"] = nick:gsub ("(:)", " ")
-- set priority
if not properties["priority.session"] then
local path = properties["api.v4l2.path"] or "/dev/video100"
local dev = path:gsub ("/dev/video(%d+)", "%1")
properties["priority.session"] = 1000 - (tonumber (dev) * 10)
end
event:set_data ("node-properties", properties)
end
}:register ()

View File

@@ -0,0 +1,154 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- create-item.lua script takes pipewire nodes and creates session items (a.k.a
-- linkable) objects out of them.
cutils = require ("common-utils")
log = Log.open_topic ("s-node")
items = {}
function configProperties (node)
local properties = node.properties
local media_class = properties ["media.class"] or ""
-- ensure a media.type is set
if not properties ["media.type"] then
for _, i in ipairs ({ "Audio", "Video", "Midi" }) do
if media_class:find (i) then
properties ["media.type"] = i
break
end
end
end
properties ["item.node"] = node
properties ["item.node.direction"] =
cutils.mediaClassToDirection (media_class)
properties ["item.node.type"] =
media_class:find ("^Stream/") and "stream" or "device"
properties ["item.plugged.usec"] = GLib.get_monotonic_time ()
properties ["item.features.no-dsp"] =
Settings.get_boolean ("node.features.audio.no-dsp")
properties ["item.features.monitor"] =
Settings.get_boolean ("node.features.audio.monitor-ports")
properties ["item.features.control-port"] =
Settings.get_boolean ("node.features.audio.control-port")
properties ["node.id"] = node ["bound-id"]
-- set the default media.role, if configured
-- avoid Settings.get_string(), as it will parse the default "null" value
-- as a string instead of returning nil
local default_role = Settings.get ("node.stream.default-media-role")
if default_role then
default_role = default_role:parse()
properties ["media.role"] = properties ["media.role"] or default_role
end
return properties
end
AsyncEventHook {
name = "node/create-item",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "#", "Stream/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "#", "Video/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "#", "Audio/*", type = "pw-global" },
Constraint { "wireplumber.is-virtual", "-", type = "pw" },
},
},
steps = {
start = {
next = "register",
execute = function (event, transition)
local node = event:get_subject ()
local id = node.id
local item
local item_type
local media_class = node.properties ['media.class']
if string.find (media_class, "Audio") then
item_type = "si-audio-adapter"
else
item_type = "si-node"
end
log:info (node, "creating item for node -> " .. item_type)
-- create item
item = SessionItem (item_type)
items [id] = item
-- configure item
if not item:configure (configProperties (node)) then
transition:return_error ("failed to configure item for node "
.. tostring (id))
return
end
-- activate item
item:activate (Features.ALL, function (_, e)
if e then
transition:return_error ("failed to activate item: "
.. tostring (e));
else
transition:advance ()
end
end)
end,
},
register = {
next = "none",
execute = function (event, transition)
local node = event:get_subject ()
local bound_id = node ["bound-id"]
local item = items [node.id]
log:info (item, "activated item for node " .. tostring (bound_id))
item:register ()
transition:advance ()
end,
},
},
}:register ()
SimpleEventHook {
name = "node/destroy-item",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "#", "Stream/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "#", "Video/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "#", "Audio/*", type = "pw-global" },
Constraint { "wireplumber.is-virtual", "-", type = "pw" },
},
},
execute = function (event)
local node = event:get_subject ()
local id = node.id
if items [id] then
items [id]:remove ()
items [id] = nil
end
end
}:register ()

View File

@@ -0,0 +1,111 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Logic to "forward" the format set on special filter nodes to their
-- virtual device peer node. This is for things like the "loopback" module,
-- which always comes in pairs of 2 nodes, one stream and one virtual device.
--
-- FIXME: this script can be further improved
lutils = require ("linking-utils")
log = Log.open_topic ("s-node")
function findAssociatedLinkGroupNode (si)
local si_props = si.properties
local link_group = si_props ["node.link-group"]
if link_group == nil then
return nil
end
local std_event_source = Plugin.find ("standard-event-source")
local om = std_event_source:call ("get-object-manager", "session-item")
-- get the associated media class
local assoc_direction = cutils.getTargetDirection (si_props)
local assoc_media_class = si_props ["media.type"] ..
(assoc_direction == "input" and "/Sink" or "/Source")
-- find the linkable with same link group and matching assoc media class
for assoc_si in om:iterate { type = "SiLinkable" } do
local assoc_props = assoc_si.properties
local assoc_link_group = assoc_props ["node.link-group"]
if assoc_link_group == link_group and
assoc_media_class == assoc_props ["media.class"] then
return assoc_si
end
end
return nil
end
function onLinkGroupPortsStateChanged (si, old_state, new_state)
local si_props = si.properties
-- only handle items with configured ports state
if new_state ~= "configured" then
return
end
log:info (si, "ports format changed on " .. si_props ["node.name"])
-- find associated device
local si_device = findAssociatedLinkGroupNode (si)
if si_device ~= nil then
local device_node_name = si_device.properties ["node.name"]
-- get the stream format
local f, m = si:get_ports_format ()
-- unregister the device
log:info (si_device, "unregistering " .. device_node_name)
si_device:remove ()
-- set new format in the device
log:info (si_device, "setting new format in " .. device_node_name)
si_device:set_ports_format (f, m, function (item, e)
if e ~= nil then
log:warning (item, "failed to configure ports in " ..
device_node_name .. ": " .. e)
end
-- register back the device
log:info (item, "registering " .. device_node_name)
item:register ()
end)
end
end
SimpleEventHook {
name = "node/filter-forward-format",
interests = {
EventInterest {
Constraint { "event.type", "=", "session-item-added" },
Constraint { "event.session-item.interface", "=", "linkable" },
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "media.class", "#", "Stream/*", type = "pw-global" },
},
},
execute = function (event)
local si = event:get_subject ()
-- Forward filters ports format to associated virtual devices if enabled
if Settings.get_boolean ("node.filter.forward-format") then
local si_props = si.properties
local link_group = si_props ["node.link-group"]
local si_flags = lutils:get_flags (si.id)
-- only listen for ports state changed on audio filter streams
if si_flags.ports_state_signal ~= true and
si_props ["item.factory.name"] == "si-audio-adapter" and
si_props ["item.node.type"] == "stream" and
link_group ~= nil then
si:connect ("adapter-ports-state-changed", onLinkGroupPortsStateChanged)
si_flags.ports_state_signal = true
log:info (si, "listening ports state changed on " .. si_props ["node.name"])
end
end
end
}:register ()

View File

@@ -0,0 +1,92 @@
-- WirePlumber
--
-- Copyright © 2022-2023 The WirePlumber project contributors
-- @author Dmitry Sharshakov <d3dx12.xx@gmail.com>
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic("s-node")
config = {}
config.rules = Conf.get_section_as_json("node.software-dsp.rules", Json.Array{})
-- TODO: port from Obj Manager to Hooks
clients_om = ObjectManager {
Interest { type = "client" }
}
filter_nodes = {}
hidden_nodes = {}
SimpleEventHook {
name = "node/dsp/create-dsp-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
},
},
execute = function(event)
local node = event:get_subject()
JsonUtils.match_rules (config.rules, node.properties, function (action, value)
if action == "create-filter" then
local props = value:parse (1)
log:debug("DSP rule found for " .. node.properties["node.name"])
if props["filter-graph"] then
log:debug("Loading filter graph for " .. node.properties["node.name"])
filter_nodes[node.id] = LocalModule("libpipewire-module-filter-chain", props["filter-graph"], {})
elseif props["filter-path"] then
log:debug("Loading filter graph for " .. node.properties["node.name"] .. " from disk")
local conf = Conf(props["filter-path"], {
["as-section"] = "node.software-dsp.graph",
["no-fragments"] = true
})
local err = conf:open()
if not err then
local args = conf:get_section_as_json("node.software-dsp.graph"):to_string()
filter_nodes[node.id] = LocalModule("libpipewire-module-filter-chain", args, {})
else
log:warning("Unable to load filter graph for " .. node.properties["node.name"])
end
end
if props["hide-parent"] then
log:debug("Setting permissions to '-' on " .. node.properties["node.name"] .. " for open clients")
for client in clients_om:iterate{ type = "client" } do
if not client["properties"]["wireplumber.daemon"] then
client:update_permissions{ [node["bound-id"]] = "-" }
end
end
hidden_nodes[node["bound-id"]] = node.id
end
end
end)
end
}:register()
SimpleEventHook {
name = "node/dsp/free-dsp-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-removed" },
},
},
execute = function(event)
local node = event:get_subject()
if filter_nodes[node.id] then
log:debug("Freeing filter on node " .. node.id)
filter_nodes[node.id] = nil
hidden_nodes[node["bound-id"]] = nil
end
end
}:register()
clients_om:connect("object-added", function (om, client)
for id, _ in pairs(hidden_nodes) do
if not client["properties"]["wireplumber.daemon"] then
client:update_permissions { [id] = "-" }
end
end
end)
clients_om:activate()

View File

@@ -0,0 +1,452 @@
-- WirePlumber
--
-- Copyright © 2021-2022 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- Based on restore-stream.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-node")
config = {}
config.rules = Conf.get_section_as_json ("stream.rules", Json.Array {})
-- the state storage
state = nil
state_table = nil
-- Support for the "System Sounds" volume control in pavucontrol
rs_metadata = nil
-- hook to restore stream properties & target
restore_stream_hook = SimpleEventHook {
name = "node/restore-stream",
interests = {
-- match stream nodes
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Stream/*" },
},
-- and device nodes that are not associated with any routes
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "is-absent" },
},
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "equals", "0" },
},
},
execute = function (event)
local node = event:get_subject ()
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
local key = formKey (stream_props)
if not key then
return
end
local stored_values = getStoredStreamProps (key) or {}
-- restore node Props (volumes, channelMap, etc...)
if Settings.get_boolean ("node.stream.restore-props") and stream_props ["state.restore-props"] ~= "false"
then
local props = {
"Spa:Pod:Object:Param:Props", "Props",
volume = stored_values.volume,
mute = stored_values.mute,
channelVolumes = stored_values.channelVolumes ~= nil and
stored_values.channelVolumes or buildDefaultChannelVolumes (node),
channelMap = stored_values.channelMap,
}
-- 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.volume or (props.mute ~= nil) or props.channelVolumes or props.channelMap
then
log:info (node, "restore values from " .. key)
local param = Pod.Object (props)
log:debug (param, "setting props on " .. tostring (stream_props ["node.name"]))
node:set_param ("Props", param)
end
end
-- restore the node's link target on metadata
if Settings.get_boolean ("node.stream.restore-target") and stream_props ["state.restore-target"] ~= "false"
then
if stored_values.target then
-- check first if there is a defined target in the node's properties
-- and skip restoring if this is the case (#335)
local target_in_props =
stream_props ["target.object"] or stream_props ["node.target"]
if not target_in_props then
local source = event:get_source ()
local nodes_om = source:call ("get-object-manager", "node")
local metadata_om = source:call ("get-object-manager", "metadata")
local target_node = nodes_om:lookup {
Constraint { "node.name", "=", stored_values.target, type = "pw" }
}
local metadata = metadata_om:lookup {
Constraint { "metadata.name", "=", "default" }
}
if target_node and metadata then
metadata:set (node ["bound-id"], "target.object", "Spa:Id",
target_node.properties ["object.serial"])
end
else
log:debug (node,
"Not restoring the target for " ..
tostring (stream_props ["node.name"]) ..
" because it is already set to " .. target_in_props)
end
end
end
end
}
-- store stream properties on the state file
store_stream_props_hook = SimpleEventHook {
name = "node/store-stream-props",
interests = {
-- match stream nodes
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Stream/*" },
},
-- and device nodes that are not associated with any routes
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "is-absent" },
},
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "equals", "0" },
},
},
execute = function (event)
local node = event:get_subject ()
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
if Settings.get_boolean ("node.stream.restore-props") and stream_props ["state.restore-props"] ~= "false"
then
local key = formKey (stream_props)
if not key then
return
end
local stored_values = getStoredStreamProps (key) or {}
local hasChanges = false
log:info (node, "saving stream props for " ..
tostring (stream_props ["node.name"]))
for p in node:iterate_params ("Props") do
local props = cutils.parseParam (p, "Props")
if not props then
goto skip_prop
end
if props.volume ~= stored_values.volume then
stored_values.volume = props.volume
hasChanges = true
end
if props.mute ~= stored_values.mute then
stored_values.mute = props.mute
hasChanges = true
end
if props.channelVolumes then
stored_values.channelVolumes = props.channelVolumes
hasChanges = true
end
if props.channelMap then
stored_values.channelMap = props.channelMap
hasChanges = true
end
::skip_prop::
end
if hasChanges then
saveStreamProps (key, stored_values)
end
end
end
}
-- save "target.node"/"target.object" on metadata changes
store_stream_target_hook = SimpleEventHook {
name = "node/store-stream-target-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "c", "target.object", "target.node" },
},
},
execute = function (event)
local source = event:get_source ()
local nodes_om = source:call ("get-object-manager", "node")
local props = event:get_properties ()
local subject_id = props ["event.subject.id"]
local target_key = props ["event.subject.key"]
local target_value = props ["event.subject.value"]
local node = nodes_om:lookup {
Constraint { "bound-id", "=", subject_id, type = "gobject" }
}
if not node then
return
end
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
if stream_props ["state.restore-target"] == "false" then
return
end
local key = formKey (stream_props)
if not key then
return
end
local target_name = nil
if target_value and target_value ~= "-1" then
local target_node
if target_key == "target.object" then
target_node = nodes_om:lookup {
Constraint { "object.serial", "=", target_value, type = "pw-global" }
}
else
target_node = nodes_om:lookup {
Constraint { "bound-id", "=", target_value, type = "gobject" }
}
end
if target_node then
target_name = target_node.properties ["node.name"]
end
end
log:info (node, "saving stream target for " ..
tostring (stream_props ["node.name"]) .. " -> " .. tostring (target_name))
local stored_values = getStoredStreamProps (key) or {}
stored_values.target = target_name
saveStreamProps (key, stored_values)
end
}
-- populate route-settings metadata
function populateMetadata (metadata)
-- copy state into the metadata
local key = "Output/Audio:media.role:Notification"
local p = getStoredStreamProps (key)
if p then
p.channels = p.channelMap and Json.Array (p.channelMap)
p.volumes = p.channelVolumes and Json.Array (p.channelVolumes)
p.channelMap = nil
p.channelVolumes = nil
p.target = nil
-- pipewire-pulse expects the key to be
-- "restore.stream.Output/Audio.media.role:Notification"
key = string.gsub (key, ":", ".", 1);
metadata:set (0, "restore.stream." .. key, "Spa:String:JSON",
Json.Object (p):to_string ())
end
end
-- track route-settings metadata changes
route_settings_metadata_changed_hook = SimpleEventHook {
name = "node/route-settings-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "route-settings" },
Constraint { "event.subject.key", "=",
"restore.stream.Output/Audio.media.role:Notification" },
Constraint { "event.subject.spa_type", "=", "Spa:String:JSON" },
Constraint { "event.subject.value", "is-present" },
},
},
execute = function (event)
local props = event:get_properties ()
local subject_id = props ["event.subject.id"]
local key = props ["event.subject.key"]
local value = props ["event.subject.value"]
local json = Json.Raw (value)
if json == nil or not json:is_object () then
return
end
local vparsed = json:parse ()
-- we store the key as "Output/Audio:media.role:Notification"
local key = string.sub (key, string.len ("restore.stream.") + 1)
key = string.gsub (key, "%.", ":", 1);
local stored_values = getStoredStreamProps (key) or {}
if vparsed.volume ~= nil then
stored_values.volume = vparsed.volume
end
if vparsed.mute ~= nil then
stored_values.mute = vparsed.mute
end
if vparsed.channels ~= nil then
stored_values.channelMap = vparsed.channels
end
if vparsed.volumes ~= nil then
stored_values.channelVolumes = vparsed.volumes
end
saveStreamProps (key, stored_values)
end
}
function buildDefaultChannelVolumes (node)
local node_props = node.properties
local direction = cutils.mediaClassToDirection (node_props ["media.class"] or "")
local def_vol = 1.0
local channels = 2
local res = {}
local str = node.properties["state.default-volume"]
if str ~= nil then
def_vol = tonumber (str)
elseif direction == "input" then
def_vol = Settings.get_float ("node.stream.default-capture-volume")
elseif direction == "output" then
def_vol = Settings.get_float ("node.stream.default-playback-volume")
end
for pod in node:iterate_params("Format") do
local pod_parsed = pod:parse()
if pod_parsed ~= nil then
channels = pod_parsed.properties.channels
break
end
end
log:info (node, "using default volume: " .. tostring(def_vol) ..
", channels: " .. tostring(channels))
while (#res < channels) do
table.insert(res, def_vol)
end
return res
end
function getStoredStreamProps (key)
local value = state_table [key]
if not value then
return nil
end
local json = Json.Raw (value)
if not json or not json:is_object () then
return nil
end
return json:parse ()
end
function saveStreamProps (key, p)
assert (type (p) == "table")
p.channelMap = p.channelMap and Json.Array (p.channelMap)
p.channelVolumes = p.channelVolumes and Json.Array (p.channelVolumes)
state_table [key] = Json.Object (p):to_string ()
state:save_after_timeout (state_table)
end
function formKey (properties)
local keys = {
"media.role",
"application.id",
"application.name",
"media.name",
"node.name",
}
local key_base = nil
for _, k in ipairs (keys) do
local p = properties [k]
if p then
key_base = string.format ("%s:%s:%s",
properties ["media.class"]:gsub ("^Stream/", ""), k, p)
break
end
end
return key_base
end
function toggleState (enable)
if enable and not state then
state = State ("stream-properties")
state_table = state:load ()
restore_stream_hook:register ()
store_stream_props_hook:register ()
store_stream_target_hook:register ()
route_settings_metadata_changed_hook:register ()
rs_metadata = ImplMetadata ("route-settings")
rs_metadata:activate (Features.ALL, function (m, e)
if e then
log:warning ("failed to activate route-settings metadata: " .. tostring (e))
else
populateMetadata (m)
end
end)
elseif not enable and state then
state = nil
state_table = nil
restore_stream_hook:remove ()
store_stream_props_hook:remove ()
store_stream_target_hook:remove ()
route_settings_metadata_changed_hook:remove ()
rs_metadata = nil
end
end
Settings.subscribe ("node.stream.restore-props", function ()
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))
end)
Settings.subscribe ("node.stream.restore-target", function ()
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))
end)
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))

View File

@@ -0,0 +1,65 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic ("s-node")
sources = {}
SimpleEventHook {
name = "node/suspend-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-state-changed" },
Constraint { "media.class", "matches", "Audio/*" },
},
EventInterest {
Constraint { "event.type", "=", "node-state-changed" },
Constraint { "media.class", "matches", "Video/*" },
},
},
execute = function (event)
local node = event:get_subject ()
local new_state = event:get_properties ()["event.subject.new-state"]
log:debug (node, "changed state to " .. new_state)
-- Always clear the current source if any
local id = node["bound-id"]
if sources[id] then
sources[id]:destroy()
sources[id] = nil
end
-- Add a timeout source if idle for at least 5 seconds
if new_state == "idle" or new_state == "error" then
-- honor "session.suspend-timeout-seconds" if specified
local timeout =
tonumber(node.properties["session.suspend-timeout-seconds"]) or 5
if timeout == 0 then
return
end
-- add idle timeout; multiply by 1000, timeout_add() expects ms
sources[id] = Core.timeout_add(timeout * 1000, function()
-- Suspend the node
-- but check first if the node still exists
if (node:get_active_features() & Feature.Proxy.BOUND) ~= 0 then
log:info(node, "was idle for a while; suspending ...")
node:send_command("Suspend")
end
-- Unref the source
sources[id] = nil
-- false (== G_SOURCE_REMOVE) destroys the source so that this
-- function does not get fired again after 5 seconds
return false
end)
end
end
}:register ()

View File

@@ -0,0 +1,103 @@
-- WirePlumber
--
-- Copyright © 2023 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
--
-- The script exposes a metadata object named "sm-objects" that clients can
-- use to load objects into the WirePlumber daemon process. The objects are
-- loaded as soon as the metadata is set and are destroyed when the metadata
-- is cleared.
--
-- To load an object, a client needs to set a metadata entry with:
--
-- * subject:
-- The ID of the owner of the object; you can use 0 here, but the
-- idea is to be able to restrict which clients can change and/or
-- delete these objects by using IDs of other objects appropriately
--
-- * key: "<UNIQUE-OBJECT-NAME>"
-- This is the name that will be used to identify the object.
-- If an object with the same name already exists, it will be destroyed.
-- Note that the keys are unique per subject, so you can have multiple
-- objects with the same name as long as they are owned by different subjects.
--
-- * type: "Spa:String:JSON"
--
-- * value: "{ type = <object-type>,
-- name = <object-name>,
-- args = { ...object arguments... } }"
-- The object type can be one of the following:
-- - "pw-module": loads a pipewire module: `name` and `args` are interpreted
-- just like a module entry in pipewire.conf
-- - "metadata": loads a metadata object with `metadata.name` = `name`
-- and any additional properties provided in `args`
--
on_demand_objects = {}
object_constructors = {
["pw-module"] = LocalModule,
["metadata"] = function (name, args)
local m = ImplMetadata (name, args)
m:activate (Features.ALL, function (m, e)
if e then
Log.warning ("failed to activate on-demand metadata `" .. name .. "`: " .. tostring (e))
end
end)
return m
end
}
function handle_metadata_changed (m, subject, key, type, value)
-- destroy all objects when metadata is cleared
if not key then
on_demand_objects = {}
return
end
local object_id = key .. "@" .. tostring(subject)
-- destroy existing object instance, if needed
if on_demand_objects[object_id] then
Log.debug("destroy on-demand object: " .. object_id)
on_demand_objects[object_id] = nil
end
if value then
local json = Json.Raw(value)
if not json:is_object() then
Log.warning("loading '".. object_id .. "' failed: expected JSON object, got: '" .. value .. "'")
return
end
local obj = json:parse(1)
if not obj.type then
Log.warning("loading '".. object_id .. "' failed: no object type specified")
return
end
if not obj.name then
Log.warning("loading '".. object_id .. "' failed: no object name specified")
return
end
local constructor = object_constructors[obj.type]
if not constructor then
Log.warning("loading '".. object_id .. "' failed: unknown object type: " .. obj.type)
return
end
Log.info("load on-demand object: " .. object_id .. " -> " .. obj.name)
on_demand_objects[object_id] = constructor(obj.name, obj.args)
end
end
objects_metadata = ImplMetadata ("sm-objects")
objects_metadata:activate (Features.ALL, function (m, e)
if e then
Log.warning ("failed to activate the sm-objects metadata: " .. tostring (e))
else
m:connect("changed", handle_metadata_changed)
end
end)

View File

@@ -0,0 +1,806 @@
## The WirePlumber configuration
context.spa-libs = {
## SPA factory name to library mappings
## Used to find SPA factory names. It maps a SPA factory name regular
## expression to a library name that should contain that factory.
##
## Syntax:
## <factory-name regex> = <library-name>
api.alsa.* = alsa/libspa-alsa
api.bluez5.* = bluez5/libspa-bluez5
api.v4l2.* = v4l2/libspa-v4l2
api.libcamera.* = libcamera/libspa-libcamera
audio.convert.* = audioconvert/libspa-audioconvert
support.* = support/libspa-support
}
context.modules = [
## PipeWire modules to load.
## These modules are loaded before a connection to pipewire is attempted.
## This section should be kept minimal and load only the modules that are
## necessary for the protocol to work.
##
## If ifexists is given, the module is ignored when it is not found.
## If nofail is given, module initialization failures are ignored.
##
## Syntax:
## {
## name = <module-name>
## [ args = { <key> = <value> ... } ]
## [ flags = [ ifexists | nofail ] ]
## }
# Uses RTKit to boost the data thread priority. Also allows clamping
# of utilisation when using the Completely Fair Scheduler on Linux.
{
name = libpipewire-module-rt
args = {
nice.level = -11
# rt.prio = 88
# rt.time.soft = -1
# rt.time.hard = -1
# uclamp.min = 0
# uclamp.max = 1024
}
flags = [ ifexists, nofail ]
}
## The native communication protocol.
{ name = libpipewire-module-protocol-native }
## Support for metadata objects
{ name = libpipewire-module-metadata }
]
wireplumber.profiles = {
## Syntax:
## <profile> = {
## # optional is the default
## <feature name> = [ required | optional | disabled ]
## ...
## }
# The default profile
main = {
check.no-media-session = required
metadata.sm-settings = required
support.settings = required
support.log-settings = required
metadata.sm-objects = required
hardware.audio = required
hardware.bluetooth = required
hardware.video-capture = required
policy.standard = required
}
# Profile for video-only use cases (camera & screen sharing)
video-only = {
check.no-media-session = required
metadata.sm-settings = required
support.settings = required
support.log-settings = required
metadata.sm-objects = required
hardware.audio = disabled
hardware.bluetooth = disabled
hardware.video-capture = required
policy.standard = required
}
}
wireplumber.components = [
## WirePlumber components to load.
## These components are loaded after a connection to pipewire is established.
## type is mandatory; rest of the tags are optional
##
## Syntax:
## {
## name = <component-name>
## type = <component-type>
## arguments = { <json object> }
##
## # Feature that this component provides
## provides = <feature>
##
## # List of features that must be provided before this component is loaded
## requires = [ <features> ]
##
## # List of features that would offer additional functionality if provided
## # but are not strictly required
## wants = [ <features> ]
## }
## Check to avoid loading together with media-session
{
name = ensure-no-media-session, type = built-in
provides = check.no-media-session
}
## Makes a secondary connection to PipeWire for exporting objects
{
name = export-core, type = built-in
provides = support.export-core
}
## Enables creating local nodes that are exported to pipewire
## This is needed for LocalNode() / WpImplNode
## This should be used with the export-core to avoid protocol deadlocks,
## unless you know what you are doing
{
name = libpipewire-module-client-node, type = pw-module
provides = pw.client-node
wants = [ support.export-core ]
}
## Enables creating local devices that are exported to pipewire
## This is needed for SpaDevice() / WpSpaDevice
## This should be used with the export-core to avoid protocol deadlocks,
## unless you know what you are doing
{
name = libpipewire-module-client-device, type = pw-module
provides = pw.client-device
wants = [ support.export-core ]
}
# Provides a node factory to create SPA nodes
# You need this to use LocalNode("spa-node-factory", ...)
{
name = libpipewire-module-spa-node-factory, type = pw-module
provides = pw.node-factory.spa
requires = [ pw.client-node ]
}
## Provides a node factory to create SPA nodes wrapped in an adapter
## You need this to use LocalNode("adapter", ...)
{
name = libpipewire-module-adapter, type = pw-module
provides = pw.node-factory.adapter
requires = [ pw.client-node ]
}
## Provides the "sm-settings" metadata object
{
name = libwireplumber-module-settings, type = module
arguments = { metadata.name = sm-settings }
provides = metadata.sm-settings
}
## Activates a global WpSettings instance, providing settings from
## the sm-settings metadata object
{
name = settings-instance, type = built-in
arguments = { metadata.name = sm-settings }
provides = support.settings
}
## Log level settings
{
name = libwireplumber-module-log-settings, type = module
provides = support.log-settings
}
## The lua scripting engine
{
name = libwireplumber-module-lua-scripting, type = module
provides = support.lua-scripting
}
## Module listening for pipewire objects to push events
{
name = libwireplumber-module-standard-event-source, type = module
provides = support.standard-event-source
}
## The shared D-Bus connection
{
name = libwireplumber-module-dbus-connection, type = module
provides = support.dbus
}
## Module managing the portal permissions
{
name = libwireplumber-module-portal-permissionstore, type = module
provides = support.portal-permissionstore
requires = [ support.dbus ]
}
## Needed for device reservation to work
{
name = libwireplumber-module-reserve-device, type = module
provides = support.reserve-device
requires = [ support.dbus ]
}
## logind integration to enable certain functionality only on the active seat
{
name = libwireplumber-module-logind, type = module
provides = support.logind
}
## Session item factories
{
name = libwireplumber-module-si-node, type = module
provides = si.node
}
{
name = libwireplumber-module-si-audio-adapter, type = module
provides = si.audio-adapter
}
{
name = libwireplumber-module-si-standard-link, type = module
provides = si.standard-link
}
## API to access default nodes from scripts
{
name = libwireplumber-module-default-nodes-api, type = module
provides = api.default-nodes
}
## API to access mixer controls
{
name = libwireplumber-module-mixer-api, type = module
provides = api.mixer
}
## API to get notified about file changes
{
name = libwireplumber-module-file-monitor-api, type = module
provides = api.file-monitor
}
## Provide the "default" pw_metadata
{
name = metadata.lua, type = script/lua
arguments = { metadata.name = default }
provides = metadata.default
}
## Provide the "filters" pw_metadata
{
name = metadata.lua, type = script/lua
arguments = { metadata.name = filters }
provides = metadata.filters
}
## Provide the "sm-objects" pw_metadata, supporting dynamic loadable objects
{
name = sm-objects.lua, type = script/lua
provides = metadata.sm-objects
}
## Device monitors' optional features
{
type = virtual, provides = monitor.alsa.reserve-device,
requires = [ support.reserve-device ]
}
{
type = virtual, provides = monitor.alsa-midi.monitoring,
requires = [ api.file-monitor ]
}
{
type = virtual, provides = monitor.bluez.seat-monitoring,
requires = [ support.logind ]
}
## Device monitors
{
name = monitors/alsa.lua, type = script/lua
provides = monitor.alsa
requires = [ support.export-core, pw.client-device ]
wants = [ monitor.alsa.reserve-device ]
}
{
name = monitors/bluez.lua, type = script/lua
provides = monitor.bluez
requires = [ support.export-core,
pw.client-device,
pw.client-node,
pw.node-factory.adapter ]
wants = [ monitor.bluez.seat-monitoring ]
}
{
name = monitors/bluez-midi.lua, type = script/lua
provides = monitor.bluez-midi
requires = [ support.export-core,
pw.client-device,
pw.client-node,
pw.node-factory.spa ]
wants = [ monitor.bluez.seat-monitoring ]
}
{
name = monitors/alsa-midi.lua, type = script/lua
provides = monitor.alsa-midi
wants = [ monitor.alsa-midi.monitoring ]
}
## v4l2 monitor hooks
{
name = monitors/v4l2/name-device.lua, type = script/lua
provides = hooks.monitor.v4l2-name-device
requires = [ support.export-core,
support.standard-event-source ]
}
{
name = monitors/v4l2/create-device.lua, type = script/lua
provides = hooks.monitor.v4l2-create-device
requires = [ support.export-core,
pw.client-device,
support.standard-event-source ]
}
{
name = monitors/v4l2/name-node.lua, type = script/lua
provides = hooks.monitor.v4l2-name-node
requires = [ support.export-core,
support.standard-event-source ]
}
{
name = monitors/v4l2/create-node.lua, type = script/lua
provides = hooks.monitor.v4l2-create-node
requires = [ support.export-core,
support.standard-event-source ]
}
{
type = virtual, provides = monitor.v4l2.hooks
wants = [ hooks.monitor.v4l2-name-device,
hooks.monitor.v4l2-create-device,
hooks.monitor.v4l2-name-node,
hooks.monitor.v4l2-create-node ]
}
# enumerate-device.lua needs rest of the monitor hooks to be loaded first.
{
name = monitors/v4l2/enumerate-device.lua, type = script/lua
provides = hooks.monitor.v4l2-enumerate-device
requires = [ support.export-core,
pw.client-device,
support.standard-event-source,
monitor.v4l2.hooks ]
}
{
type = virtual, provides = monitor.v4l2
wants = [ hooks.monitor.v4l2-enumerate-device,
monitor.v4l2.hooks ]
}
## libcamera monitor hooks
{
name = monitors/libcamera/name-device.lua, type = script/lua
provides = hooks.monitor.libcamera-name-device
requires = [ support.export-core,
support.standard-event-source ]
}
{
name = monitors/libcamera/create-device.lua, type = script/lua
provides = hooks.monitor.libcamera-create-device
requires = [ support.export-core,
pw.client-device,
support.standard-event-source ]
}
{
name = monitors/libcamera/name-node.lua, type = script/lua
provides = hooks.monitor.libcamera-name-node
requires = [ support.export-core,
support.standard-event-source ]
}
{
name = monitors/libcamera/create-node.lua, type = script/lua
provides = hooks.monitor.libcamera-create-node
requires = [ support.export-core,
support.standard-event-source ]
}
{
type = virtual, provides = monitor.libcamera.hooks
wants = [ hooks.monitor.libcamera-name-device,
hooks.monitor.libcamera-create-device,
hooks.monitor.libcamera-name-node,
hooks.monitor.libcamera-create-node ]
}
# enumerate-device.lua needs rest of the monitor hooks to be loaded first.
{
name = monitors/libcamera/enumerate-device.lua, type = script/lua
provides = hooks.monitor.libcamera-enumerate-device
requires = [ support.export-core,
pw.client-device,
support.standard-event-source,
monitor.libcamera.hooks ]
}
{
type = virtual, provides = monitor.libcamera
wants = [ hooks.monitor.libcamera-enumerate-device,
monitor.libcamera.hooks ]
}
## Client access configuration hooks
{
name = client/access-default.lua, type = script/lua
provides = script.client.access-default
}
{
name = client/access-portal.lua, type = script/lua
provides = script.client.access-portal
requires = [ support.portal-permissionstore ]
}
{
name = client/access-snap.lua, type = script/lua
provides = script.client.access-snap
}
{
type = virtual, provides = policy.client.access
wants = [ script.client.access-default,
script.client.access-portal,
script.client.access-snap ]
}
## Device profile selection hooks
{
name = device/select-profile.lua, type = script/lua
provides = hooks.device.profile.select
}
{
name = device/find-preferred-profile.lua, type = script/lua
provides = hooks.device.profile.find-preferred
}
{
name = device/find-best-profile.lua, type = script/lua
provides = hooks.device.profile.find-best
}
{
name = device/state-profile.lua, type = script/lua
provides = hooks.device.profile.state
}
{
name = device/apply-profile.lua, type = script/lua
provides = hooks.device.profile.apply
}
{
name = device/autoswitch-bluetooth-profile.lua, type = script/lua
provides = hooks.device.profile.autoswitch-bluetooth
}
{
type = virtual, provides = policy.device.profile
requires = [ hooks.device.profile.select,
hooks.device.profile.autoswitch-bluetooth,
hooks.device.profile.apply ]
wants = [ hooks.device.profile.find-best, hooks.device.profile.find-preferred,
hooks.device.profile.state ]
}
# Device route selection hooks
{
name = device/select-routes.lua, type = script/lua
provides = hooks.device.routes.select
}
{
name = device/find-best-routes.lua, type = script/lua
provides = hooks.device.routes.find-best
}
{
name = device/state-routes.lua, type = script/lua
provides = hooks.device.routes.state
}
{
name = device/apply-routes.lua, type = script/lua
provides = hooks.device.routes.apply
}
{
type = virtual, provides = policy.device.routes
requires = [ hooks.device.routes.select,
hooks.device.routes.apply ]
wants = [ hooks.device.routes.find-best,
hooks.device.routes.state ]
}
## Default nodes selection hooks
{
name = default-nodes/rescan.lua, type = script/lua
provides = hooks.default-nodes.rescan
}
{
name = default-nodes/find-selected-default-node.lua, type = script/lua
provides = hooks.default-nodes.find-selected
requires = [ metadata.default ]
}
{
name = default-nodes/find-best-default-node.lua, type = script/lua
provides = hooks.default-nodes.find-best
}
{
name = default-nodes/state-default-nodes.lua, type = script/lua
provides = hooks.default-nodes.state
requires = [ metadata.default ]
}
{
name = default-nodes/apply-default-node.lua, type = script/lua,
provides = hooks.default-nodes.apply
requires = [ metadata.default ]
}
{
type = virtual, provides = policy.default-nodes
requires = [ hooks.default-nodes.rescan,
hooks.default-nodes.apply ]
wants = [ hooks.default-nodes.find-selected,
hooks.default-nodes.find-best,
hooks.default-nodes.state ]
}
## Node configuration hooks
{
name = node/create-item.lua, type = script/lua
provides = hooks.node.create-session-item
requires = [ si.audio-adapter, si.node ]
}
{
name = node/suspend-node.lua, type = script/lua
provides = hooks.node.suspend
}
{
name = node/state-stream.lua, type = script/lua
provides = hooks.stream.state
}
{
name = node/filter-forward-format.lua, type = script/lua
provides = hooks.filter.forward-format
}
{
type = virtual, provides = policy.node
requires = [ hooks.node.create-session-item ]
wants = [ hooks.node.suspend
hooks.stream.state
hooks.filter.forward-format ]
}
{
name = node/software-dsp.lua, type = script/lua
provides = node.software-dsp
}
## Linking hooks
{
name = linking/rescan.lua, type = script/lua
provides = hooks.linking.rescan
}
{
name = linking/find-media-role-target.lua, type = script/lua
provides = hooks.linking.target.find-media-role
}
{
name = linking/find-defined-target.lua, type = script/lua
provides = hooks.linking.target.find-defined
}
{
name = linking/find-filter-target.lua, type = script/lua
provides = hooks.linking.target.find-filter
requires = [ metadata.filters ]
}
{
name = linking/find-default-target.lua, type = script/lua
provides = hooks.linking.target.find-default
requires = [ api.default-nodes ]
}
{
name = linking/find-best-target.lua, type = script/lua
provides = hooks.linking.target.find-best
requires = [ metadata.filters ]
}
{
name = linking/get-filter-from-target.lua, type = script/lua
provides = hooks.linking.target.get-filter-from
requires = [ metadata.filters ]
}
{
name = linking/prepare-link.lua, type = script/lua
provides = hooks.linking.target.prepare-link
requires = [ api.default-nodes ]
}
{
name = linking/link-target.lua, type = script/lua
provides = hooks.linking.target.link
requires = [ si.standard-link ]
}
{
type = virtual, provides = policy.linking.standard
requires = [ hooks.linking.rescan,
hooks.linking.target.prepare-link,
hooks.linking.target.link ]
wants = [ hooks.linking.target.find-media-role,
hooks.linking.target.find-defined,
hooks.linking.target.find-filter,
hooks.linking.target.find-default,
hooks.linking.target.find-best,
hooks.linking.target.get-filter-from ]
}
## Linking: Role-based priority system
{
name = linking/rescan-media-role-links.lua, type = script/lua
provides = hooks.linking.role-based.rescan
requires = [ api.mixer ]
}
{
type = virtual, provides = policy.linking.role-based
requires = [ policy.linking.standard,
hooks.linking.role-based.rescan ]
}
## Standard policy definition
{
type = virtual, provides = policy.standard
requires = [ policy.client.access
policy.device.profile
policy.device.routes
policy.default-nodes
policy.linking.standard
policy.linking.role-based
policy.node
support.standard-event-source ]
}
## Load targets
{
type = virtual, provides = hardware.audio
wants = [ monitor.alsa, monitor.alsa-midi ]
}
{
type = virtual, provides = hardware.bluetooth
wants = [ monitor.bluez, monitor.bluez-midi ]
}
{
type = virtual, provides = hardware.video-capture
wants = [ monitor.v4l2, monitor.libcamera ]
}
]
wireplumber.components.rules = [
## Rules to apply on top of wireplumber.components
## Syntax:
## {
## matches = [
## {
## [ <key> = <value> ... ]
## }
## ...
## ]
## actions = {
## <override|merge> = {
## [ <key> = <value> ... ]
## }
## ...
## }
## }
{
matches = [
{
type = "script/lua"
}
]
actions = {
merge = {
requires = [ support.lua-scripting ]
}
}
}
]
wireplumber.settings.schema = {
## Bluetooth
bluetooth.use-persistent-storage = {
description = "Whether to use persistent BT storage or not"
type = "bool"
default = true
}
bluetooth.autoswitch-to-headset-profile = {
description = "Whether to autoswitch to BT headset profile or not"
type = "bool"
default = true
}
## Device
device.restore-profile = {
description = "Whether to restore device profile or not"
type = "bool"
default = true
}
device.restore-routes = {
description = "Whether to restore device routes or not"
type = "bool"
default = true
}
device.routes.default-sink-volume = {
description = "The default volume for sink devices"
type = "float"
default = 0.064
min = 0.0
max = 1.0
}
device.routes.default-source-volume = {
description = "The default volume for source devices"
type = "float"
default = 1.0
min = 0.0
max = 1.0
}
## Linking
linking.role-based.duck-level = {
description = "The volume level to apply when ducking (= reducing volume for a higher priority stream to be audible) in the role-based linking policy"
type = "float"
default = 0.3
min = 0.0
max = 1.0
}
linking.allow-moving-streams = {
description = "Whether to allow metadata to move streams at runtime or not"
type = "bool"
default = true
}
linking.follow-default-target = {
description = "Whether to allow streams follow the default device or not"
type = "bool"
default = true
}
## Monitor
monitor.camera-discovery-timeout = {
description = "The camera discovery timeout in milliseconds"
type = "int"
default = 1000
min = 0
max = 60000
}
## Node
node.features.audio.no-dsp = {
description = "Whether to never convert audio to F32 format or not"
type = "bool"
default = false
}
node.features.audio.monitor-ports = {
description = "Whether to enable monitor ports on audio nodes or not"
type = "bool"
default = true
}
node.features.audio.control-port = {
description = "Whether to enable control ports on audio nodes or not"
type = "bool"
default = false
}
node.stream.restore-props = {
description = "Whether to restore properties on stream nodes or not"
type = "bool"
default = true
}
node.stream.restore-target = {
description = "Whether to restore target on stream nodes or not"
type = "bool"
default = true
}
node.stream.default-playback-volume = {
description = "The default volume for playback nodes"
type = "float"
default = 1.0
min = 0.0
max = 1.0
}
node.stream.default-capture-volume = {
description = "The default volume for capture nodes"
type = "float"
default = 1.0
min = 0.0
max = 1.0
}
node.stream.default-media-role = {
description = "A media.role to assign on streams that have none specified"
type = "string"
default = null
}
node.filter.forward-format = {
description = "Whether to forward format on filter nodes or not"
type = "bool"
default = false
}
node.restore-default-targets = {
description = "Whether to restore default targets or not"
type = "bool"
default = true
}
}

View File

@@ -0,0 +1,23 @@
# ALSA node property overrides for virtual machine hardware
monitor.alsa.rules = [
# Generic PCI cards on any VM type
{
matches = [
{
node.name = "~alsa_input.pci.*"
cpu.vm.name = "~.*"
}
{
node.name = "~alsa_output.pci.*"
cpu.vm.name = "~.*"
}
]
actions = {
update-props = {
api.alsa.period-size = 1024
api.alsa.headroom = 2048
}
}
}
]