From 59f98e38e4b001ee6c7731d7277683c925f479de Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 5 May 2026 14:40:50 -0400 Subject: [PATCH 01/12] lib: Add collection proxy API This proxy uses the metadata interface to represent collections. --- lib/wp/collection.c | 991 ++++++++++++++++++++++++++++++++++++++ lib/wp/collection.h | 102 ++++ lib/wp/meson.build | 2 + lib/wp/private/registry.c | 21 +- lib/wp/wp.c | 1 + lib/wp/wp.h | 1 + tests/wp/collection.c | 281 +++++++++++ tests/wp/meson.build | 7 + 8 files changed, 1402 insertions(+), 4 deletions(-) create mode 100644 lib/wp/collection.c create mode 100644 lib/wp/collection.h create mode 100644 tests/wp/collection.c diff --git a/lib/wp/collection.c b/lib/wp/collection.c new file mode 100644 index 00000000..6b3fc1bb --- /dev/null +++ b/lib/wp/collection.c @@ -0,0 +1,991 @@ +/* WirePlumber + * + * Copyright © 2026 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include "collection.h" +#include "core.h" +#include "log.h" +#include "error.h" +#include "wpenums.h" + +#include +#include +#include + +WP_DEFINE_LOCAL_LOG_TOPIC ("wp-collection") + +/*! \defgroup wpcollection WpCollection */ +/*! + * \struct WpCollection + * + * The WpCollection class allows accessing the properties and methods of + * PipeWire collection object (`struct pw_collection`). + * + * A WpCollection is constructed internally when a new collection object appears + * on the PipeWire registry and it is made available through the WpObjectManager + * API. + * + * \gsignals + * + * \par global_added + * \parblock + * \code + * void + * global_added_callback (WpCollection * self, + * guint global_id, + * gpointer user_data) + * \endcode + * Emitted when a gobal was added into the collection + * + * Parameters: + * - `global_id` - the added global id + * + * Flags: G_SIGNAL_RUN_LAST + * \endparblock + * + * \par global_removed + * \parblock + * \code + * void + * global_removed_callback (WpCollection * self, + * guint global_id, + * gpointer user_data) + * \endcode + * Emitted when a gobal was removed from the collection + * + * Parameters: + * - `global_id` - the removed global id + * + * Flags: G_SIGNAL_RUN_LAST + * \endparblock + */ + +enum { + SIGNAL_GLOBAL_COLLECTED, + SIGNAL_GLOBAL_DROPPED, + N_SIGNALS, +}; + +static guint32 signals[N_SIGNALS] = {0}; + +/* data structure */ + +struct item +{ + uint32_t subject; + gchar *key; + gchar *type; + gchar *value; +}; + +static void +set_item (struct item * item, uint32_t subject, const char * key, + const char * type, const char * value) +{ + item->subject = subject; + item->key = key ? g_strdup (key) : NULL; + item->type = type ? g_strdup (type) : NULL; + item->value = value ? g_strdup (value) : NULL; +} + +static void +clear_item (struct item * item) +{ + g_clear_pointer (&item->key, g_free); + g_clear_pointer (&item->type, g_free); + g_clear_pointer (&item->value, g_free); + spa_zero (*item); +} + +static struct item * +find_item (struct pw_array * metadata, uint32_t subject, const char * key) +{ + struct item *item; + + pw_array_for_each (item, metadata) { + if (item->subject == subject && (key == NULL || !strcmp (item->key, key))) + return item; + } + return NULL; +} + +static void +clear_items (struct pw_array * metadata) +{ + struct item *item; + + pw_array_consume (item, metadata) { + clear_item (item); + pw_array_remove (metadata, item); + } + pw_array_reset (metadata); +} + +struct _WpCollection +{ + WpGlobalProxy parent; + + struct pw_metadata *iface; + struct spa_hook listener; + struct pw_array metadata; + gboolean listener_added; +}; + + +G_DEFINE_TYPE (WpCollection, wp_collection, WP_TYPE_GLOBAL_PROXY) + +static void +wp_collection_init (WpCollection * self) +{ + pw_array_init (&self->metadata, 4096); +} + +static void +wp_collection_finalize (GObject * object) +{ + WpCollection * self = WP_COLLECTION (object); + + pw_array_clear (&self->metadata); + + G_OBJECT_CLASS (wp_collection_parent_class)->finalize (object); +} + +static WpObjectFeatures +wp_collection_get_supported_features (WpObject * object) +{ + return WP_PROXY_FEATURE_BOUND | WP_COLLECTION_FEATURE_DATA; +} + +enum { + STEP_BIND = WP_TRANSITION_STEP_CUSTOM_START, + STEP_CACHE +}; + +static guint +wp_collection_activate_get_next_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + g_return_val_if_fail ( + missing & (WP_PROXY_FEATURE_BOUND | WP_COLLECTION_FEATURE_DATA), + WP_TRANSITION_STEP_ERROR); + + /* bind if not already bound */ + if (missing & WP_PROXY_FEATURE_BOUND) + return STEP_BIND; + else + return STEP_CACHE; +} + +static void +wp_collection_activate_execute_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + switch (step) { + case STEP_CACHE: + /* just wait for initial_sync_done() */ + break; + default: + WP_OBJECT_CLASS (wp_collection_parent_class)-> + activate_execute_step (object, transition, step, missing); + break; + } +} + +static int +metadata_event_property (void *object, uint32_t subject, const char *key, + const char *type, const char *value) +{ + WpCollection *self = WP_COLLECTION (object); + struct item *item = NULL; + + /* Clear subject if key is NULL */ + if (key == NULL) { + while (true) { + guint32 global_id = SPA_ID_INVALID; + + item = find_item (&self->metadata, subject, NULL); + if (item == NULL) + break; + + pw_array_remove (&self->metadata, item); + + if (spa_atou32 (item->key, &global_id, 0)) + g_signal_emit (self, signals[SIGNAL_GLOBAL_DROPPED], 0, global_id); + + clear_item (item); + } + return 0; + } + + item = find_item (&self->metadata, subject, key); + if (item == NULL) { + if (value == NULL) + return 0; + item = pw_array_add (&self->metadata, sizeof (*item)); + if (item == NULL) + return -errno; + } else { + clear_item (item); + } + + if (value != NULL) { + guint32 global_id = SPA_ID_INVALID; + set_item (item, subject, key, type, value); + if (spa_atou32 (key, &global_id, 0)) + g_signal_emit (self, signals[SIGNAL_GLOBAL_COLLECTED], 0, global_id); + } else { + guint32 global_id = SPA_ID_INVALID; + pw_array_remove (&self->metadata, item); + if (spa_atou32 (key, &global_id, 0)) + g_signal_emit (self, signals[SIGNAL_GLOBAL_DROPPED], 0, global_id); + } + + return 0; +} + +static const struct pw_metadata_events metadata_events = { + PW_VERSION_METADATA_EVENTS, + .property = metadata_event_property, +}; + +static void +wp_collection_pw_proxy_created (WpProxy * proxy, struct pw_proxy * pw_proxy) +{ + WpCollection *self = WP_COLLECTION (proxy); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + self->iface = (struct pw_metadata *) pw_proxy; + pw_metadata_add_listener (self->iface, &self->listener, &metadata_events, + self); + self->listener_added = TRUE; + + wp_object_update_features (WP_OBJECT (self), WP_COLLECTION_FEATURE_DATA, 0); +} + +static void +wp_collection_pw_proxy_destroyed (WpProxy * proxy) +{ + WpCollection *self = WP_COLLECTION (proxy); + + if (self->listener_added) { + spa_hook_remove (&self->listener); + self->listener_added = FALSE; + } + clear_items (&self->metadata); + wp_object_update_features (WP_OBJECT (self), 0, WP_COLLECTION_FEATURE_DATA); + + WP_PROXY_CLASS (wp_collection_parent_class)->pw_proxy_destroyed (proxy); +} + +static void +wp_collection_class_init (WpCollectionClass * klass) +{ + GObjectClass *object_class = (GObjectClass *) klass; + WpObjectClass *wpobject_class = (WpObjectClass *) klass; + WpProxyClass *proxy_class = (WpProxyClass *) klass; + + object_class->finalize = wp_collection_finalize; + + wpobject_class->get_supported_features = wp_collection_get_supported_features; + wpobject_class->activate_get_next_step = wp_collection_activate_get_next_step; + wpobject_class->activate_execute_step = wp_collection_activate_execute_step; + + proxy_class->pw_iface_type = PW_TYPE_INTERFACE_Metadata; + proxy_class->pw_iface_version = PW_VERSION_METADATA; + proxy_class->pw_proxy_created = wp_collection_pw_proxy_created; + proxy_class->pw_proxy_destroyed = wp_collection_pw_proxy_destroyed; + + signals[SIGNAL_GLOBAL_COLLECTED] = g_signal_new ("global-collected", + G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL, + G_TYPE_NONE, 1, G_TYPE_UINT); + + signals[SIGNAL_GLOBAL_DROPPED] = g_signal_new ("global-dropped", + G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL, + G_TYPE_NONE, 1, G_TYPE_UINT); +} + +/*! + * \brief Gets the name of the collection + * + * \ingroup wpcollection + * \param self the collection object + * \returns (nullable): the name of the collection + */ +const gchar * +wp_collection_get_name (WpCollection * self) +{ + g_autoptr (WpProperties) props = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION (self), NULL); + + props = wp_global_proxy_get_global_properties (WP_GLOBAL_PROXY (self)); + return props ? wp_properties_get (props, "collection.name") : NULL; +} + +/*! + * \brief Gets the total number of globals the collection has collected + * + * \ingroup wpcollection + * \param self the collection object + * \returns the total number of globals the collection has collected + */ +gsize +wp_collection_get_size (WpCollection * self) +{ + g_return_val_if_fail (WP_IS_COLLECTION (self), 0); + + return pw_array_get_len (&self->metadata, struct item); +} + +/*! + * \brief Checks if a global ID is collected in the collection + * + * \ingroup wpcollection + * \param self the collection object + * \param global_id the global ID to check + * \returns TRUE if the global ID is collected in the colleciton, FALSE + * otherwise. + */ +gboolean +wp_collection_contains_global (WpCollection * self, guint32 global_id) +{ + struct item *item; + + g_return_val_if_fail (WP_IS_COLLECTION (self), FALSE); + + pw_array_for_each (item, &self->metadata) { + guint32 id = SPA_ID_INVALID; + if (spa_atou32 (item->key, &id, 0) && id == global_id) + return TRUE; + } + return FALSE; +} + +/*! + * \brief Collects a global ID into the collection. + * + * \ingroup wpcollection + * \param self the collection object + * \param global_id the global ID to collect + */ +void +wp_collection_collect_global (WpCollection * self, guint32 global_id) +{ + g_autofree gchar *global_id_str = NULL; + + g_return_if_fail (WP_IS_COLLECTION (self)); + g_return_if_fail (global_id != SPA_ID_INVALID); + + global_id_str = g_strdup_printf ("%u", global_id); + pw_metadata_set_property (self->iface, 0, global_id_str, NULL, "collected"); +} + +/*! + * \brief Drops a global ID from the collection. + * + * \ingroup wpcollection + * \param self the collection object + * \param global_id the global ID to drop + */ +void +wp_collection_drop_global (WpCollection * self, guint32 global_id) +{ + g_autofree gchar *global_id_str = NULL; + + g_return_if_fail (WP_IS_COLLECTION (self)); + g_return_if_fail (global_id != SPA_ID_INVALID); + + global_id_str = g_strdup_printf ("%u", global_id); + pw_metadata_set_property (self->iface, 0, global_id_str, NULL, NULL); +} + +/*! + * \brief Clears all collected global Ids + * \ingroup wpcollection + * \param self the collection object + */ +void +wp_collection_clear (WpCollection * self) +{ + g_return_if_fail (WP_IS_COLLECTION (self)); + + pw_metadata_clear (self->iface); +} + +struct collection_iterator_data +{ + WpCollection *collection; + const struct item *item; +}; + +static void +collection_iterator_reset (WpIterator *it) +{ + struct collection_iterator_data *it_data = wp_iterator_get_user_data (it); + WpCollection *self = it_data->collection; + + it_data->item = pw_array_first (&self->metadata); +} + +static gboolean +collection_iterator_next (WpIterator *it, GValue *item) +{ + struct collection_iterator_data *it_data = wp_iterator_get_user_data (it); + WpCollection *self = it_data->collection; + + while (pw_array_check (&self->metadata, it_data->item)) { + guint global_id = SPA_ID_INVALID; + if (spa_atou32 (it_data->item->key, &global_id, 0)) { + g_value_init (item, G_TYPE_UINT); + g_value_set_uint (item, global_id); + it_data->item++; + return TRUE; + } + it_data->item++; + } + return FALSE; +} + +static void +collection_iterator_finalize (WpIterator *it) +{ + struct collection_iterator_data *it_data = wp_iterator_get_user_data (it); + g_object_unref (it_data->collection); +} + +static const WpIteratorMethods collection_iterator_methods = { + .version = WP_ITERATOR_METHODS_VERSION, + .reset = collection_iterator_reset, + .next = collection_iterator_next, + .finalize = collection_iterator_finalize, +}; + +/*! + * \brief Iterates over all the collected global IDs. + * + * \ingroup wpcollection + * \param self a collection object + * \returns (transfer full): an iterator that iterates over the collected global + * IDs. The type of the iterator item is an unsigned integer. + */ +WpIterator * +wp_collection_new_iterator (WpCollection * self) +{ + g_autoptr (WpIterator) it = NULL; + struct collection_iterator_data *it_data; + + g_return_val_if_fail (WP_IS_COLLECTION (self), NULL); + + it = wp_iterator_new (&collection_iterator_methods, + sizeof (struct collection_iterator_data)); + it_data = wp_iterator_get_user_data (it); + it_data->collection = g_object_ref (self); + it_data->item = pw_array_first (&self->metadata); + return g_steal_pointer (&it); +} + +/*! + * \struct WpImplCollection + * The implementation side of the collection object. + * + * Activate this object with at least WP_PROXY_FEATURE_BOUND to export it to + * PipeWire. + */ +struct _WpImplCollection +{ + WpProxy parent; + + gchar *name; + WpProperties *properties; + + struct pw_metadata *iface; + struct pw_impl_metadata *impl; + struct spa_hook listener; + struct pw_array metadata; +}; + +enum { + PROP_0, + PROP_NAME, + PROP_PROPERTIES, +}; + +enum { + IMPL_SIGNAL_GLOBAL_COLLECTED, + IMPL_SIGNAL_GLOBAL_DROPPED, + IMPL_N_SIGNALS, +}; + +static guint32 impl_signals[IMPL_N_SIGNALS] = {0}; + +G_DEFINE_TYPE (WpImplCollection, wp_impl_collection, WP_TYPE_PROXY) + +static void +wp_impl_collection_init (WpImplCollection * self) +{ + pw_array_init (&self->metadata, 4096); +} + +static int +impl_metadata_event_property (void *object, uint32_t subject, const char *key, + const char *type, const char *value) +{ + WpImplCollection *self = WP_IMPL_COLLECTION (object); + struct item *item = NULL; + + /* Clear subject if key is NULL */ + if (key == NULL) { + while (true) { + guint32 global_id = SPA_ID_INVALID; + + item = find_item (&self->metadata, subject, NULL); + if (item == NULL) + break; + + pw_array_remove (&self->metadata, item); + + if (spa_atou32 (item->key, &global_id, 0)) + g_signal_emit (self, impl_signals[IMPL_SIGNAL_GLOBAL_DROPPED], 0, + global_id); + + clear_item (item); + } + return 0; + } + + item = find_item (&self->metadata, subject, key); + if (item == NULL) { + if (value == NULL) + return 0; + item = pw_array_add (&self->metadata, sizeof (*item)); + if (item == NULL) + return -errno; + } else { + clear_item (item); + } + + if (value != NULL) { + guint32 global_id = SPA_ID_INVALID; + set_item (item, subject, key, type, value); + if (spa_atou32 (key, &global_id, 0)) + g_signal_emit (self, impl_signals[IMPL_SIGNAL_GLOBAL_COLLECTED], 0, + global_id); + } else { + guint32 global_id = SPA_ID_INVALID; + pw_array_remove (&self->metadata, item); + if (spa_atou32 (key, &global_id, 0)) + g_signal_emit (self, impl_signals[IMPL_SIGNAL_GLOBAL_DROPPED], 0, + global_id); + } + + return 0; +} + +static const struct pw_impl_metadata_events impl_metadata_events = { + PW_VERSION_IMPL_METADATA_EVENTS, + .property = impl_metadata_event_property, +}; + +static void +wp_impl_collection_constructed (GObject *object) +{ + WpImplCollection *self = WP_IMPL_COLLECTION (object); + g_autoptr (WpCore) core = NULL; + struct pw_context *pw_context; + struct pw_properties *props; + + core = wp_object_get_core (WP_OBJECT (self)); + g_return_if_fail (core); + pw_context = wp_core_get_pw_context (core); + g_return_if_fail (pw_context); + + /* Make sure the collection name and flag is set */ + if (!self->properties) + self->properties = wp_properties_new_empty (); + wp_properties_set (self->properties, "collection.name", self->name); + wp_properties_set (self->properties, "wireplumber.collection", "true"); + + props = wp_properties_to_pw_properties (self->properties); + self->impl = pw_context_create_metadata (pw_context, self->name, props , 0); + g_return_if_fail (self->impl); + self->iface = pw_impl_metadata_get_implementation (self->impl); + g_return_if_fail (self->iface); + + pw_impl_metadata_add_listener (self->impl, &self->listener, + &impl_metadata_events, self); + + G_OBJECT_CLASS (wp_impl_collection_parent_class)->constructed (object); +} + +static void +wp_impl_collection_finalize (GObject * object) +{ + WpImplCollection *self = WP_IMPL_COLLECTION (object); + + pw_array_clear (&self->metadata); + spa_hook_remove (&self->listener); + g_clear_pointer (&self->impl, pw_impl_metadata_destroy); + g_clear_pointer (&self->properties, wp_properties_unref); + g_clear_pointer (&self->name, g_free); + + G_OBJECT_CLASS (wp_impl_collection_parent_class)->finalize (object); +} + +static void +wp_impl_collection_set_property (GObject * object, guint property_id, + const GValue * value, GParamSpec * pspec) +{ + WpImplCollection *self = WP_IMPL_COLLECTION (object); + + switch (property_id) { + case PROP_NAME: + g_clear_pointer (&self->name, g_free); + self->name = g_value_dup_string (value); + break; + case PROP_PROPERTIES: + g_clear_pointer (&self->properties, wp_properties_unref); + self->properties = g_value_dup_boxed (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_impl_collection_get_property (GObject * object, guint property_id, + GValue * value, GParamSpec * pspec) +{ + WpImplCollection *self = WP_IMPL_COLLECTION (object); + + switch (property_id) { + case PROP_NAME: + g_value_set_string (value, self->name); + break; + case PROP_PROPERTIES: + g_value_set_boxed (value, self->properties); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +enum { + STEP_EXPORT = WP_TRANSITION_STEP_CUSTOM_START, +}; + +static WpObjectFeatures +wp_impl_collection_get_supported_features (WpObject * object) +{ + return WP_PROXY_FEATURE_BOUND; +} + +static guint +wp_impl_collection_activate_get_next_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + g_return_val_if_fail (missing & (WP_PROXY_FEATURE_BOUND), + WP_TRANSITION_STEP_ERROR); + + return STEP_EXPORT; +} + +static void +wp_impl_collection_activate_execute_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + WpImplCollection *self = WP_IMPL_COLLECTION (object); + + switch (step) { + case STEP_EXPORT: { + g_autoptr (WpCore) core = wp_object_get_core (object); + struct pw_core *pw_core = wp_core_get_pw_core (core); + const struct pw_properties *props = NULL; + + /* no pw_core -> we are not connected */ + if (!pw_core) { + wp_transition_return_error (WP_TRANSITION (transition), g_error_new ( + WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED, + "The WirePlumber core is not connected; " + "object cannot be exported to PipeWire")); + return; + } + + props = pw_impl_metadata_get_properties (self->impl); + wp_proxy_set_pw_proxy (WP_PROXY (self), pw_core_export (pw_core, + PW_TYPE_INTERFACE_Metadata, &props->dict, self->iface, 0)); + break; + } + default: + WP_OBJECT_CLASS (wp_impl_collection_parent_class)-> + activate_execute_step (object, transition, step, missing); + break; + } +} + +static void +wp_impl_collection_class_init (WpImplCollectionClass * klass) +{ + GObjectClass *object_class = (GObjectClass *) klass; + WpObjectClass *wpobject_class = (WpObjectClass *) klass; + WpProxyClass *proxy_class = (WpProxyClass *) klass; + + object_class->constructed = wp_impl_collection_constructed; + object_class->finalize = wp_impl_collection_finalize; + object_class->set_property = wp_impl_collection_set_property; + object_class->get_property = wp_impl_collection_get_property; + + wpobject_class->get_supported_features = + wp_impl_collection_get_supported_features; + wpobject_class->activate_get_next_step = + wp_impl_collection_activate_get_next_step; + wpobject_class->activate_execute_step = + wp_impl_collection_activate_execute_step; + + proxy_class->pw_iface_type = PW_TYPE_INTERFACE_Metadata; + proxy_class->pw_iface_version = PW_VERSION_METADATA; + + g_object_class_install_property (object_class, PROP_NAME, + g_param_spec_string ("name", "name", "The collection name", NULL, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + + g_object_class_install_property (object_class, PROP_PROPERTIES, + g_param_spec_boxed ("properties", "properties", + "The collection properties", WP_TYPE_PROPERTIES, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + + impl_signals[IMPL_SIGNAL_GLOBAL_COLLECTED] = g_signal_new ("global-collected", + G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL, + G_TYPE_NONE, 1, G_TYPE_UINT); + + impl_signals[IMPL_SIGNAL_GLOBAL_DROPPED] = g_signal_new ("global-dropped", + G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL, + G_TYPE_NONE, 1, G_TYPE_UINT); +} + +/*! + * \brief Creates a new impl collection + * \ingroup wpcollection + * \param core the core + * \param name (nullable): the collection name + * \param properties (nullable) (transfer full): the collection properties + * \returns (transfer full): a new WpImplCollection + */ +WpImplCollection * +wp_impl_collection_new (WpCore * core, const gchar *name, + WpProperties *properties) +{ + g_autoptr (WpProperties) props = properties; + + g_return_val_if_fail (WP_IS_CORE (core), NULL); + + return g_object_new (WP_TYPE_IMPL_COLLECTION, + "core", core, + "name", name, + "properties", props, + NULL); +} + +/*! + * \brief Gets the properties of the impl collection + * + * \ingroup wpcollection + * \param self the impl collection object + \returns (transfer full) (nullable): the properties of the impl collection + */ +WpProperties * +wp_impl_collection_get_properties (WpImplCollection *self) +{ + g_return_val_if_fail (WP_IS_IMPL_COLLECTION (self), NULL); + + return self->properties ? wp_properties_ref (self->properties) : NULL; +} + +/*! + * \brief Gets the name of the impl collection + * + * \ingroup wpcollection + * \param self the impl collection object + * \returns (nullable): the name of the impl collection + */ +const gchar * +wp_impl_collection_get_name (WpImplCollection *self) +{ + g_return_val_if_fail (WP_IS_IMPL_COLLECTION (self), NULL); + + return self->name; +} + +/*! + * \brief Gets the total number of globals the impl collection has collected + * + * \ingroup wpcollection + * \param self the impl collection object + * \returns the total number of globals the impl collection has collected + */ +gsize +wp_impl_collection_get_size (WpImplCollection * self) +{ + g_return_val_if_fail (WP_IS_IMPL_COLLECTION (self), 0); + + return pw_array_get_len (&self->metadata, struct item); +} + +/*! + * \brief Checks if a global ID is collected in the impl collection + * + * \ingroup wpcollection + * \param self the impl collection object + * \param global_id the global ID to check + * \returns TRUE if the global ID is collected in the colleciton, FALSE + * otherwise. + */ +gboolean +wp_impl_collection_contains_global (WpImplCollection * self, guint32 global_id) +{ + struct item *item; + + g_return_val_if_fail (WP_IS_IMPL_COLLECTION (self), FALSE); + + pw_array_for_each (item, &self->metadata) { + guint32 id = SPA_ID_INVALID; + if (spa_atou32 (item->key, &id, 0) && id == global_id) + return TRUE; + } + return FALSE; +} + +/*! + * \brief Collects a global ID into the impl collection. + * + * \ingroup wpcollection + * \param self the impl collection object + * \param global_id the global ID to collect + */ +void +wp_impl_collection_collect_global (WpImplCollection * self, guint32 global_id) +{ + g_autofree gchar *global_id_str = NULL; + + g_return_if_fail (WP_IS_IMPL_COLLECTION (self)); + g_return_if_fail (global_id != SPA_ID_INVALID); + + global_id_str = g_strdup_printf ("%u", global_id); + pw_metadata_set_property (self->iface, 0, global_id_str, NULL, "collected"); +} + +/*! + * \brief Drops a global ID from the impl collection. + * + * \ingroup wpcollection + * \param self the impl collection object + * \param global_id the global ID to drop + */ +void +wp_impl_collection_drop_global (WpImplCollection * self, guint32 global_id) +{ + g_autofree gchar *global_id_str = NULL; + + g_return_if_fail (WP_IS_IMPL_COLLECTION (self)); + g_return_if_fail (global_id != SPA_ID_INVALID); + + global_id_str = g_strdup_printf ("%u", global_id); + pw_metadata_set_property (self->iface, 0, global_id_str, NULL, NULL); +} + +/*! + * \brief Clears all collected global Ids + * \ingroup wpcollection + * \param self the impl collection object + */ +void +wp_impl_collection_clear (WpImplCollection * self) +{ + g_return_if_fail (WP_IS_IMPL_COLLECTION (self)); + + pw_metadata_clear (self->iface); +} + +struct impl_collection_iterator_data +{ + WpImplCollection *impl_collection; + const struct item *item; +}; + +static void +impl_collection_iterator_reset (WpIterator *it) +{ + struct impl_collection_iterator_data *it_data = + wp_iterator_get_user_data (it); + WpImplCollection *self = it_data->impl_collection; + + it_data->item = pw_array_first (&self->metadata); +} + +static gboolean +impl_collection_iterator_next (WpIterator *it, GValue *item) +{ + struct impl_collection_iterator_data *it_data = + wp_iterator_get_user_data (it); + WpImplCollection *self = it_data->impl_collection; + + while (pw_array_check (&self->metadata, it_data->item)) { + guint global_id = SPA_ID_INVALID; + if (spa_atou32 (it_data->item->key, &global_id, 0)) { + g_value_init (item, G_TYPE_UINT); + g_value_set_uint (item, global_id); + it_data->item++; + return TRUE; + } + it_data->item++; + } + return FALSE; +} + +static void +impl_collection_iterator_finalize (WpIterator *it) +{ + struct impl_collection_iterator_data *it_data = + wp_iterator_get_user_data (it); + g_object_unref (it_data->impl_collection); +} + +static const WpIteratorMethods impl_collection_iterator_methods = { + .version = WP_ITERATOR_METHODS_VERSION, + .reset = impl_collection_iterator_reset, + .next = impl_collection_iterator_next, + .finalize = impl_collection_iterator_finalize, +}; + +/*! + * \brief Iterates over all the collected global IDs. + * + * \ingroup wpcollection + * \param self an impl collection object + * \returns (transfer full): an iterator that iterates over the collected global + * IDs. The type of the iterator item is an unsigned integer. + */ +WpIterator * +wp_impl_collection_new_iterator (WpImplCollection * self) +{ + g_autoptr (WpIterator) it = NULL; + struct impl_collection_iterator_data *it_data; + + g_return_val_if_fail (WP_IS_IMPL_COLLECTION (self), NULL); + + it = wp_iterator_new (&impl_collection_iterator_methods, + sizeof (struct impl_collection_iterator_data)); + it_data = wp_iterator_get_user_data (it); + it_data->impl_collection = g_object_ref (self); + it_data->item = pw_array_first (&self->metadata); + return g_steal_pointer (&it); +} diff --git a/lib/wp/collection.h b/lib/wp/collection.h new file mode 100644 index 00000000..83ee9616 --- /dev/null +++ b/lib/wp/collection.h @@ -0,0 +1,102 @@ +/* WirePlumber + * + * Copyright © 2026 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#ifndef __WIREPLUMBER_COLLECTION_H__ +#define __WIREPLUMBER_COLLECTION_H__ + +#include "global-proxy.h" + +G_BEGIN_DECLS + +/*! + * \brief An extension of WpProxyFeatures for WpCollection objects + * \ingroup wpcollection + */ +typedef enum { /*< flags >*/ + /*! caches collection data locally */ + WP_COLLECTION_FEATURE_DATA = (WP_PROXY_FEATURE_CUSTOM_START << 0), +} WpCollectionFeatures; + +/*! + * \brief The WpCollection GType + * \ingroup wpcollection + */ +#define WP_TYPE_COLLECTION (wp_collection_get_type ()) + +WP_API +G_DECLARE_FINAL_TYPE (WpCollection, wp_collection, WP, COLLECTION, + WpGlobalProxy) + +WP_API +const gchar *wp_collection_get_name (WpCollection * self); + +WP_API +gsize wp_collection_get_size (WpCollection * self); + +WP_API +gboolean wp_collection_contains_global (WpCollection * self, guint32 global_id); + +WP_API +void wp_collection_collect_global (WpCollection * self, guint32 global_id); + +WP_API +void wp_collection_drop_global (WpCollection * self, guint32 global_id); + +WP_API +void wp_collection_clear (WpCollection * self); + +WP_API +WpIterator * wp_collection_new_iterator (WpCollection * self); + + +/* WpImplCollection */ + +/*! + * \brief The WpImplCollection GType + * \ingroup wpcollection + */ +#define WP_TYPE_IMPL_COLLECTION (wp_impl_collection_get_type ()) + +WP_API +G_DECLARE_FINAL_TYPE (WpImplCollection, wp_impl_collection, WP, IMPL_COLLECTION, + WpProxy) + +WP_API +WpImplCollection * wp_impl_collection_new (WpCore * core, const gchar *name, + WpProperties *properties); + +WP_API +WpProperties * wp_impl_collection_get_properties (WpImplCollection *self); + +WP_API +const gchar * wp_impl_collection_get_name (WpImplCollection *self); + +WP_API +gsize wp_impl_collection_get_size (WpImplCollection * self); + +WP_API +gboolean wp_impl_collection_contains_global (WpImplCollection * self, + guint32 global_id); + +WP_API +void wp_impl_collection_collect_global (WpImplCollection * self, + guint32 global_id); + +WP_API +void wp_impl_collection_drop_global (WpImplCollection * self, + guint32 global_id); + +WP_API +void wp_impl_collection_clear (WpImplCollection * self); + +WP_API +WpIterator * wp_impl_collection_new_iterator (WpImplCollection * self); + +G_END_DECLS + +#endif diff --git a/lib/wp/meson.build b/lib/wp/meson.build index c3ff7473..4b0bb42f 100644 --- a/lib/wp/meson.build +++ b/lib/wp/meson.build @@ -4,6 +4,7 @@ wp_lib_sources = files( 'component-loader.c', 'conf.c', 'core.c', + 'collection.c', 'device.c', 'error.c', 'event.c', @@ -52,6 +53,7 @@ wp_lib_headers = files( 'component-loader.h', 'conf.h', 'core.h', + 'collection.h', 'defs.h', 'device.h', 'error.h', diff --git a/lib/wp/private/registry.c b/lib/wp/private/registry.c index 9ad99f41..8edd679e 100644 --- a/lib/wp/private/registry.c +++ b/lib/wp/private/registry.c @@ -6,9 +6,12 @@ * SPDX-License-Identifier: MIT */ +#include + #include "registry.h" #include "object-manager.h" #include "log.h" +#include "collection.h" WP_DEFINE_LOCAL_LOG_TOPIC ("wp-registry") @@ -73,16 +76,26 @@ object_manager_destroyed (gpointer data, GObject * om) /* find the subclass of WpPipewireGloabl that can handle the given pipewire interface type of the given version */ static inline GType -find_proxy_instance_type (const char * type, guint32 version) +find_proxy_instance_type (const char * type, guint32 version, + const struct spa_dict *props) { - g_autofree GType *children; + g_autofree GType *children = NULL; guint n_children; + /* Check if this is a collection */ + if (g_str_equal (type, PW_TYPE_INTERFACE_Metadata) && + version == PW_VERSION_METADATA && + props && spa_atob (spa_dict_lookup (props, "wireplumber.collection"))) { + return WP_TYPE_COLLECTION; + } + + /* Otherwise find the matching proxy non-collection type */ children = g_type_children (WP_TYPE_GLOBAL_PROXY, &n_children); for (guint i = 0; i < n_children; i++) { WpProxyClass *klass = (WpProxyClass *) g_type_class_ref (children[i]); - if (g_strcmp0 (klass->pw_iface_type, type) == 0 && + if (children[i] != WP_TYPE_COLLECTION && + g_strcmp0 (klass->pw_iface_type, type) == 0 && klass->pw_iface_version == version) { g_type_class_unref (klass); return children[i]; @@ -100,7 +113,7 @@ registry_global (void *data, uint32_t id, uint32_t permissions, const char *type, uint32_t version, const struct spa_dict *props) { WpRegistry *self = data; - GType gtype = find_proxy_instance_type (type, version); + GType gtype = find_proxy_instance_type (type, version, props); wp_debug_object (wp_registry_get_core (self), "global:%u perm:0x%x type:%s/%u -> %s", diff --git a/lib/wp/wp.c b/lib/wp/wp.c index 924b8ab1..ec6c18be 100644 --- a/lib/wp/wp.c +++ b/lib/wp/wp.c @@ -52,6 +52,7 @@ wp_init (WpInitFlags flags) g_type_ensure (WP_TYPE_NODE); g_type_ensure (WP_TYPE_PORT); g_type_ensure (WP_TYPE_FACTORY); + g_type_ensure (WP_TYPE_COLLECTION); } /*! diff --git a/lib/wp/wp.h b/lib/wp/wp.h index 7ef4e685..37f6d81b 100644 --- a/lib/wp/wp.h +++ b/lib/wp/wp.h @@ -14,6 +14,7 @@ #include "component-loader.h" #include "conf.h" #include "core.h" +#include "collection.h" #include "device.h" #include "error.h" #include "event-dispatcher.h" diff --git a/tests/wp/collection.c b/tests/wp/collection.c new file mode 100644 index 00000000..d94fadbe --- /dev/null +++ b/tests/wp/collection.c @@ -0,0 +1,281 @@ +/* WirePlumber + * + * Copyright © 2026 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include + +#include "../common/base-test-fixture.h" + +typedef struct { + WpBaseTestFixture base; + + WpObjectManager *om; + WpCollection *collection; + + guint32 last_collected_id; + guint32 last_dropped_id; +} TestFixture; + +static void +test_collection_setup (TestFixture *self, gconstpointer user_data) +{ + wp_base_test_fixture_setup (&self->base, WP_BASE_TEST_FLAG_CLIENT_CORE); + self->om = wp_object_manager_new (); +} + +static void +test_collection_teardown (TestFixture *self, gconstpointer user_data) +{ + g_clear_object (&self->om); + wp_base_test_fixture_teardown (&self->base); +} + +static void +on_collection_added (WpObjectManager *om, WpCollection *collection, + TestFixture *fixture) +{ + g_assert_true (WP_IS_COLLECTION (collection)); + + g_assert_null (fixture->collection); + fixture->collection = WP_COLLECTION (collection); + + g_main_loop_quit (fixture->base.loop); +} + +static void +on_collection_removed (WpObjectManager *om, WpCollection *collection, + TestFixture *fixture) +{ + g_assert_true (WP_IS_COLLECTION (collection)); + + g_assert_nonnull (fixture->collection); + fixture->collection = NULL; + + g_main_loop_quit (fixture->base.loop); +} + +static void +on_global_collected (WpCollection *om, guint32 global_id, TestFixture *fixture) +{ + fixture->last_collected_id = global_id; + g_main_loop_quit (fixture->base.loop); +} + +static void +on_global_dropped (WpCollection *om, guint32 global_id, TestFixture *fixture) +{ + fixture->last_dropped_id = global_id; + g_main_loop_quit (fixture->base.loop); +} + +static void +test_impl_collection_activated (WpObject * impl_collection, GAsyncResult * res, + TestFixture *fixture) +{ + g_autoptr (GError) error = NULL; + + g_assert_true (wp_object_activate_finish (impl_collection, res, &error)); + g_assert_no_error (error); + + g_assert_true (WP_IS_IMPL_COLLECTION (impl_collection)); + + g_main_loop_quit (fixture->base.loop); +} + +static void +test_collection_basic (TestFixture *fixture, gconstpointer data) +{ + g_autoptr (WpImplCollection) impl_collection = NULL; + + /* Install object manager on the client side */ + g_signal_connect (fixture->om, "object-added", + (GCallback) on_collection_added, fixture); + g_signal_connect (fixture->om, "object-removed", + (GCallback) on_collection_removed, fixture); + wp_object_manager_add_interest (fixture->om, WP_TYPE_COLLECTION, NULL); + wp_object_manager_request_object_features (fixture->om, WP_TYPE_COLLECTION, + WP_OBJECT_FEATURES_ALL); + wp_core_install_object_manager (fixture->base.client_core, fixture->om); + + /* Create the collection */ + impl_collection = wp_impl_collection_new (fixture->base.core, "my-collection", + NULL); + g_assert_nonnull (impl_collection); + + /* Export the collection */ + wp_object_activate (WP_OBJECT (impl_collection), WP_OBJECT_FEATURES_ALL, + NULL, (GAsyncReadyCallback) test_impl_collection_activated, fixture); + g_main_loop_run (fixture->base.loop); + + /* Run again so the collection is added in the client object manager */ + g_assert_null (fixture->collection); + g_main_loop_run (fixture->base.loop); + g_assert_cmpuint (wp_object_manager_get_n_objects (fixture->om), ==, 1); + g_assert_nonnull (fixture->collection); + + /* Check name */ + { + const gchar *name = wp_collection_get_name (fixture->collection); + g_assert_cmpstr (name, ==, "my-collection"); + } + + /* Check properties */ + { + g_autoptr (WpProperties) props = NULL; + props = wp_global_proxy_get_global_properties ( + WP_GLOBAL_PROXY (fixture->collection)); + const gchar *str = wp_properties_get (props, "wireplumber.collection"); + g_assert_cmpstr (str, ==, "true"); + } + + /* Handle collection signals */ + g_signal_connect (fixture->collection, "global-collected", + (GCallback) on_global_collected, fixture); + g_signal_connect (fixture->collection, "global-dropped", + (GCallback) on_global_dropped, fixture); + + /* Make sure collection does not have any globals */ + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 0); + + /* Collect the first global */ + fixture->last_collected_id = 0; + wp_collection_collect_global (fixture->collection, 42); + g_main_loop_run (fixture->base.loop); + g_assert_cmpuint (fixture->last_collected_id, ==, 42); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 1); + g_assert_true (wp_collection_contains_global (fixture->collection, 42)); + g_assert_cmpuint (wp_impl_collection_get_size (impl_collection), ==, 1); + g_assert_true (wp_impl_collection_contains_global (impl_collection, 42)); + + /* Collect the second global */ + fixture->last_collected_id = 0; + wp_collection_collect_global (fixture->collection, 99); + g_main_loop_run (fixture->base.loop); + g_assert_cmpuint (fixture->last_collected_id, ==, 99); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 2); + g_assert_true (wp_collection_contains_global (fixture->collection, 99)); + g_assert_cmpuint (wp_impl_collection_get_size (impl_collection), ==, 2); + g_assert_true (wp_impl_collection_contains_global (impl_collection, 99)); + + /* Iterate the globals from the client side */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + gboolean has_first = FALSE; + gboolean has_second = FALSE; + guint count = 0; + + iter = wp_collection_new_iterator (fixture->collection); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + guint32 global_id = g_value_get_uint (&val); + if (global_id == 42) + has_first = TRUE; + if (global_id == 99) + has_second = TRUE; + count++; + g_value_unset (&val); + } + + g_assert_true (has_first); + g_assert_true (has_second); + g_assert_cmpuint (count, ==, 2); + } + + /* Iterate the globals from the impl side */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + gboolean has_first = FALSE; + gboolean has_second = FALSE; + guint count = 0; + + iter = wp_impl_collection_new_iterator (impl_collection); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + guint32 global_id = g_value_get_uint (&val); + if (global_id == 42) + has_first = TRUE; + if (global_id == 99) + has_second = TRUE; + count++; + g_value_unset (&val); + } + + g_assert_true (has_first); + g_assert_true (has_second); + g_assert_cmpuint (count, ==, 2); + } + + /* Drop the first global */ + fixture->last_dropped_id = 0; + wp_collection_drop_global (fixture->collection, 42); + g_main_loop_run (fixture->base.loop); + g_assert_cmpuint (fixture->last_dropped_id, ==, 42); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 1); + g_assert_false (wp_collection_contains_global (fixture->collection, 42)); + g_assert_cmpuint (wp_impl_collection_get_size (impl_collection), ==, 1); + g_assert_false (wp_impl_collection_contains_global (impl_collection, 42)); + + /* Collect an existing global and make sure nothing happens */ + fixture->last_collected_id = 0; + wp_collection_collect_global (fixture->collection, 99); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 1); + g_assert_true (wp_collection_contains_global (fixture->collection, 99)); + g_assert_true (wp_impl_collection_contains_global (impl_collection, 99)); + + /* Drop an unexisting global and make sure nothing happens */ + fixture->last_dropped_id = 0; + wp_collection_drop_global (fixture->collection, 42); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 1); + g_assert_false (wp_collection_contains_global (fixture->collection, 42)); + g_assert_cmpuint (wp_impl_collection_get_size (impl_collection), ==, 1); + g_assert_false (wp_impl_collection_contains_global (impl_collection, 42)); + + /* Drop the second global */ + fixture->last_dropped_id = 0; + wp_collection_drop_global (fixture->collection, 99); + g_main_loop_run (fixture->base.loop); + g_assert_cmpuint (fixture->last_dropped_id, ==, 99); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 0); + g_assert_false (wp_collection_contains_global (fixture->collection, 99)); + g_assert_false (wp_impl_collection_contains_global (impl_collection, 99)); + + /* Collect a global from the impl side */ + fixture->last_collected_id = 0; + wp_impl_collection_collect_global (impl_collection, 36); + g_main_loop_run (fixture->base.loop); + g_assert_cmpuint (fixture->last_collected_id, ==, 36); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 1); + g_assert_true (wp_collection_contains_global (fixture->collection, 36)); + g_assert_cmpuint (wp_impl_collection_get_size (impl_collection), ==, 1); + g_assert_true (wp_impl_collection_contains_global (impl_collection, 36)); + + /* Drop the global from the impl size */ + fixture->last_dropped_id = 0; + wp_impl_collection_drop_global (impl_collection, 36); + g_main_loop_run (fixture->base.loop); + g_assert_cmpuint (fixture->last_dropped_id, ==, 36); + g_assert_cmpuint (wp_collection_get_size (fixture->collection), ==, 0); + g_assert_false (wp_collection_contains_global (fixture->collection, 36)); + g_assert_cmpuint (wp_impl_collection_get_size (impl_collection), ==, 0); + g_assert_false (wp_impl_collection_contains_global (impl_collection, 36)); +} + +gint +main (gint argc, gchar *argv[]) +{ + g_test_init (&argc, &argv, NULL); + wp_init (WP_INIT_ALL); + + g_test_add ("/wp/collection/basic", TestFixture, NULL, + test_collection_setup, test_collection_basic, test_collection_teardown); + + return g_test_run (); +} diff --git a/tests/wp/meson.build b/tests/wp/meson.build index cc0b9ac8..8c4ab9a6 100644 --- a/tests/wp/meson.build +++ b/tests/wp/meson.build @@ -3,6 +3,13 @@ common_env = common_test_env common_env.set('G_TEST_SRCDIR', meson.current_source_dir()) common_env.set('G_TEST_BUILDDIR', meson.current_build_dir()) +test( + 'test-collection', + executable('test-collection', 'collection.c', + dependencies: common_deps), + env: common_env, +) + test( 'test-component-loader', executable('test-component-loader', 'component-loader.c', From fd8d36e73b43f66e465f32069ed2c4db4a52e8ee Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Mon, 27 Apr 2026 13:26:42 -0400 Subject: [PATCH 02/12] wpctl: Add new 'collections' command This new command shows information about collections. --- src/tools/wpctl.c | 141 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/src/tools/wpctl.c b/src/tools/wpctl.c index eb60778f..6ab50c54 100644 --- a/src/tools/wpctl.c +++ b/src/tools/wpctl.c @@ -108,6 +108,10 @@ static struct { gboolean reset; } settings; + struct { + const gchar *collection_name; + } collections; + struct { guint64 id; const char *level; @@ -1765,6 +1769,133 @@ out: g_main_loop_quit (self->loop); } +/* collections */ + +static gboolean +collections_parse_positional (gint argc, gchar ** argv, GError **error) +{ + cmdline.collections.collection_name = NULL; + + if (argc >= 3) + cmdline.collections.collection_name = argv[2]; + + return TRUE; +} + +static gboolean +collections_prepare (WpCtl * self, GError ** error) +{ + wp_object_manager_add_interest (self->om, WP_TYPE_GLOBAL_PROXY, NULL); + wp_object_manager_request_object_features (self->om, WP_TYPE_GLOBAL_PROXY, + WP_OBJECT_FEATURES_ALL); + return TRUE; +} + +static void +print_global (WpGlobalProxy *global) +{ + g_autoptr (WpProperties) props = NULL; + const gchar *global_name = NULL; + gchar global_type = '-'; + guint32 bound_id; + + props = wp_global_proxy_get_global_properties (global); + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + + if (WP_IS_NODE (global)) { + global_name = wp_properties_get (props, "node.name"); + global_type = 'n'; + } else if (WP_IS_PORT (global)) { + global_name = wp_properties_get (props, "port.name"); + global_type = 'p'; + } else if (WP_IS_DEVICE (global)) { + global_name = wp_properties_get (props, "device.name"); + global_type = 'd'; + } else if (WP_IS_CLIENT (global)) { + global_name = wp_properties_get (props, "client.name"); + global_type = 'c'; + } else if (WP_IS_METADATA (global)) { + global_name = wp_properties_get (props, "metadata.name"); + global_type = 'm'; + } else if (WP_IS_FACTORY (global)) { + global_name = wp_properties_get (props, "factory.name"); + global_type = 'f'; + } else if (WP_IS_COLLECTION (global)) { + global_name = wp_properties_get (props, "collection.name"); + global_type = 'o'; + } + + g_print (" [%c] %4u. %s\n", global_type, bound_id, + global_name ? global_name : "UNKNOWN"); +} + +static void +print_collection (WpCtl * self, WpCollection *collection) +{ + const gchar *collection_name = NULL; + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint32 bound_id; + + collection_name = wp_collection_get_name (collection); + bound_id = wp_proxy_get_bound_id (WP_PROXY (collection)); + + g_print ("%4u. %s\n", bound_id, collection_name); + + iter = wp_collection_new_iterator (collection); + while (wp_iterator_next (iter, &val)) { + guint32 global_id = g_value_get_uint (&val); + g_autoptr (WpGlobalProxy) global = NULL; + global = wp_object_manager_lookup (self->om, WP_TYPE_GLOBAL_PROXY, + WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=u", global_id, NULL); + if (global) + print_global (global); + g_value_unset (&val); + } + + printf ("\n"); +} + +static void +print_collections (WpCtl * self) +{ + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + printf ("Collections:\n\n"); + + it = wp_object_manager_new_filtered_iterator (self->om, WP_TYPE_COLLECTION, + NULL); + while (wp_iterator_next (it, &item)) { + WpCollection *collection = g_value_get_object (&item); + print_collection (self, collection); + g_value_unset (&item); + } +} + +static void +collections_run (WpCtl * self) +{ + const gchar *collection_name = cmdline.collections.collection_name; + + /* Print all collections if name is not provided */ + if (collection_name) { + g_autoptr (WpCollection) collection = wp_object_manager_lookup (self->om, + WP_TYPE_COLLECTION, WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "wireplumber.collection", "=s", "true", + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, "collection.name", "=s", + collection_name, NULL); + if (collection) + print_collection (self, collection); + else + printf ("Collection '%s' does not exist\n", collection_name); + } else { + print_collections (self); + } + + wp_core_sync (self->core, NULL, (GAsyncReadyCallback) async_quit, self); +} + /* set-log-level */ static gboolean @@ -2023,6 +2154,16 @@ static const struct subcommand { .prepare = settings_prepare, .run = settings_run, }, + { + .name = "collections", + .positional_args = "[COLLECTION]", + .summary = "Shows information about collections", + .description = NULL, + .entries = { { NULL } }, + .parse_positional = collections_parse_positional, + .prepare = collections_prepare, + .run = collections_run, + }, { .name = "set-log-level", .positional_args = "[ID] LEVEL", From bfdec9e65346d8f7e2a16f516cebdce5f3084cbf Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 10 Dec 2025 12:06:45 -0500 Subject: [PATCH 03/12] m-lua-scripting: Add WpCollection and WpImplCollection Lua APIs This allows using the new collections API in Lua scripts. --- modules/module-lua-scripting/api/api.c | 164 +++++++++++++++++++++++ modules/module-lua-scripting/api/api.lua | 1 + 2 files changed, 165 insertions(+) diff --git a/modules/module-lua-scripting/api/api.c b/modules/module-lua-scripting/api/api.c index 3b9dfdde..07186556 100644 --- a/modules/module-lua-scripting/api/api.c +++ b/modules/module-lua-scripting/api/api.c @@ -2179,6 +2179,166 @@ static const luaL_Reg properties_funcs[] = { { NULL, NULL } }; +/* WpCollection */ + +static int +collection_get_name (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + lua_pushstring (L, wp_collection_get_name (c)); + return 1; +} + +static int +collection_get_size (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + lua_pushinteger (L, wp_collection_get_size (c)); + return 1; +} + +static int +collection_contains_global (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpGlobalProxy *g = wplua_checkobject (L, 2, WP_TYPE_GLOBAL_PROXY); + guint32 global_id = wp_proxy_get_bound_id (WP_PROXY (g)); + lua_pushboolean (L, wp_collection_contains_global (c, global_id)); + return 1; +} + +static int +collection_collect_global (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpGlobalProxy *g = wplua_checkobject (L, 2, WP_TYPE_GLOBAL_PROXY); + guint32 global_id = wp_proxy_get_bound_id (WP_PROXY (g)); + wp_collection_collect_global (c, global_id); + return 0; +} + +static int +collection_drop_global (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpGlobalProxy *g = wplua_checkobject (L, 2, WP_TYPE_GLOBAL_PROXY); + guint32 global_id = wp_proxy_get_bound_id (WP_PROXY (g)); + wp_collection_drop_global (c, global_id); + return 0; +} + +static int +collection_iterate (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpIterator *it = wp_collection_new_iterator (c); + return push_wpiterator (L, it); +} + +static const luaL_Reg collection_funcs[] = { + { "get_name", collection_get_name }, + { "get_size", collection_get_size }, + { "contains_global", collection_contains_global }, + { "collect_global", collection_collect_global }, + { "drop_global", collection_drop_global }, + { "iterate", collection_iterate }, + { NULL, NULL } +}; + + +/* WpImplCollection */ + +static int +impl_collection_new (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + g_autoptr (WpProperties) props = NULL; + + if (lua_istable (L, 2)) + props = wplua_table_to_properties (L, 2); + else if (!lua_isnone (L, 2) && !lua_isnil (L, 2)) + props = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES)); + else + props = wp_properties_new_empty (); + + wplua_pushobject (L, wp_impl_collection_new (get_wp_export_core (L), + name, wp_properties_ref (props))); + return 1; +} + +static int +impl_collection_get_properties (lua_State *L) +{ + WpImplCollection *c = wplua_checkobject (L, 1, WP_TYPE_IMPL_COLLECTION); + WpProperties *props = wp_impl_collection_get_properties (c); + wplua_pushboxed (L, WP_TYPE_PROPERTIES, props); + return 1; +} + +static int +impl_collection_get_name (lua_State *L) +{ + WpImplCollection *c = wplua_checkobject (L, 1, WP_TYPE_IMPL_COLLECTION); + lua_pushstring (L, wp_impl_collection_get_name (c)); + return 1; +} + +static int +impl_collection_get_size (lua_State *L) +{ + WpImplCollection *c = wplua_checkobject (L, 1, WP_TYPE_IMPL_COLLECTION); + lua_pushinteger (L, wp_impl_collection_get_size (c)); + return 1; +} + +static int +impl_collection_contains_global (lua_State *L) +{ + WpImplCollection *c = wplua_checkobject (L, 1, WP_TYPE_IMPL_COLLECTION); + WpGlobalProxy *g = wplua_checkobject (L, 2, WP_TYPE_GLOBAL_PROXY); + guint32 global_id = wp_proxy_get_bound_id (WP_PROXY (g)); + lua_pushboolean (L, wp_impl_collection_contains_global (c, global_id)); + return 1; +} + +static int +impl_collection_collect_global (lua_State *L) +{ + WpImplCollection *c = wplua_checkobject (L, 1, WP_TYPE_IMPL_COLLECTION); + guint32 global_id = luaL_checkinteger (L, 2); + wp_impl_collection_collect_global (c, global_id); + return 0; +} + +static int +impl_collection_drop_global (lua_State *L) +{ + WpImplCollection *c = wplua_checkobject (L, 1, WP_TYPE_IMPL_COLLECTION); + guint32 global_id = luaL_checkinteger (L, 2); + wp_impl_collection_drop_global (c, global_id); + return 0; +} + +static int +impl_collection_iterate (lua_State *L) +{ + WpImplCollection *c = wplua_checkobject (L, 1, WP_TYPE_IMPL_COLLECTION); + WpIterator *it = wp_impl_collection_new_iterator (c); + return push_wpiterator (L, it); +} + +static const luaL_Reg impl_collection_funcs[] = { + { "get_properties", impl_collection_get_properties }, + { "get_name", impl_collection_get_name }, + { "get_size", impl_collection_get_size }, + { "contains_global", impl_collection_contains_global }, + { "collect_global", impl_collection_collect_global }, + { "drop_global", impl_collection_drop_global }, + { "iterate", impl_collection_iterate }, + { NULL, NULL } +}; + + /* WpSettings */ static int @@ -3284,6 +3444,10 @@ wp_lua_scripting_api_init (lua_State *L) properties_new, properties_funcs); wplua_register_type_methods (L, WP_TYPE_PERMISSION_MANAGER, permission_manager_new, permission_manager_funcs); + wplua_register_type_methods (L, WP_TYPE_COLLECTION, + NULL, collection_funcs); + wplua_register_type_methods (L, WP_TYPE_IMPL_COLLECTION, + impl_collection_new, impl_collection_funcs); if (!wplua_load_uri (L, URI_API, &error) || !wplua_pcall (L, 0, 0, &error)) { diff --git a/modules/module-lua-scripting/api/api.lua b/modules/module-lua-scripting/api/api.lua index fe9dcdcf..7c3c18df 100644 --- a/modules/module-lua-scripting/api/api.lua +++ b/modules/module-lua-scripting/api/api.lua @@ -236,6 +236,7 @@ SANDBOX_EXPORT = { State = WpState_new, LocalModule = WpImplModule_new, ImplMetadata = WpImplMetadata_new, + ImplCollection = WpImplCollection_new, Settings = WpSettings, Conf = WpConf, JsonUtils = JsonUtils, From 35cd7b5c5f7c5efa70584cb088028f85070855c3 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 24 Apr 2026 09:09:01 -0400 Subject: [PATCH 04/12] m-standard-event-source: Emit '*-collected' and '*-dropped' events These events are emitted when a global was collected into a collection or dropped from a collection respectively. --- modules/module-standard-event-source.c | 105 +++++++++++++++++++++---- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/modules/module-standard-event-source.c b/modules/module-standard-event-source.c index e23696bc..e0f5d34b 100644 --- a/modules/module-standard-event-source.c +++ b/modules/module-standard-event-source.c @@ -31,6 +31,7 @@ typedef enum { OBJECT_TYPE_CLIENT, OBJECT_TYPE_DEVICE, OBJECT_TYPE_METADATA, + OBJECT_TYPE_COLLECTION, N_OBJECT_TYPES, OBJECT_TYPE_INVALID = N_OBJECT_TYPES } ObjectType; @@ -65,6 +66,7 @@ rescan_context_get_type (void) struct _WpStandardEventSource { WpPlugin parent; + WpObjectManager *globals_om; WpObjectManager *oms[N_OBJECT_TYPES]; WpEventHook *rescan_done_hook; gboolean rescan_scheduled[N_RESCAN_CONTEXTS]; @@ -93,6 +95,7 @@ object_type_to_gtype (ObjectType type) case OBJECT_TYPE_CLIENT: return WP_TYPE_CLIENT; case OBJECT_TYPE_DEVICE: return WP_TYPE_DEVICE; case OBJECT_TYPE_METADATA: return WP_TYPE_METADATA; + case OBJECT_TYPE_COLLECTION: return WP_TYPE_COLLECTION; default: g_assert_not_reached (); } @@ -115,6 +118,8 @@ type_str_to_object_type (const gchar * type_str) return OBJECT_TYPE_DEVICE; else if (!g_strcmp0 (type_str, "metadata")) return OBJECT_TYPE_METADATA; + else if (!g_strcmp0 (type_str, "collection")) + return OBJECT_TYPE_COLLECTION; else return OBJECT_TYPE_INVALID; } @@ -148,6 +153,8 @@ get_object_type (gpointer obj, WpProperties **properties) return "device"; else if (WP_IS_METADATA (obj)) return "metadata"; + else if (WP_IS_COLLECTION (obj)) + return "collection"; wp_debug_object (obj, "Unknown global proxy type"); return G_OBJECT_TYPE_NAME (obj); @@ -324,6 +331,50 @@ on_metadata_changed (WpMetadata *obj, guint32 subject, wp_standard_event_source_push_event (self, "changed", obj, properties); } +static void +on_global_collected (WpCollection *obj, guint32 global_id, + WpStandardEventSource *self) +{ + g_autoptr (WpGlobalProxy) global = NULL; + const gchar *collection_name = NULL; + g_autoptr (WpProperties) properties = NULL; + + global = wp_object_manager_lookup (self->globals_om, WP_TYPE_GLOBAL_PROXY, + WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=u", global_id, NULL); + if (!global) { + wp_info_object (self, "could not find collected global %u", global_id); + return; + } + + collection_name = wp_collection_get_name (obj); + + properties = wp_properties_new_empty (); + wp_properties_set (properties, "event.subject.collection", collection_name); + wp_standard_event_source_push_event (self, "collected", global, properties); +} + +static void +on_global_dropped (WpCollection *obj, guint32 global_id, + WpStandardEventSource *self) +{ + g_autoptr (WpGlobalProxy) global = NULL; + const gchar *collection_name = NULL; + g_autoptr (WpProperties) properties = NULL; + + global = wp_object_manager_lookup (self->globals_om, WP_TYPE_GLOBAL_PROXY, + WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=u", global_id, NULL); + if (!global) { + wp_info_object (self, "could not find dropped global %u", global_id); + return; + } + + collection_name = wp_collection_get_name (obj); + + properties = wp_properties_new_empty (); + wp_properties_set (properties, "event.subject.collection", collection_name); + wp_standard_event_source_push_event (self, "dropped", global, properties); +} + static void on_params_changed (WpPipewireObject *obj, const gchar *id, WpStandardEventSource *self) @@ -368,6 +419,12 @@ on_object_added (WpObjectManager *om, WpObject *obj, WpStandardEventSource *self g_signal_connect_object (obj, "changed", G_CALLBACK (on_metadata_changed), self, 0); } + else if (WP_IS_COLLECTION (obj)) { + g_signal_connect_object (obj, "global-collected", + G_CALLBACK (on_global_collected), self, 0); + g_signal_connect_object (obj, "global-dropped", + G_CALLBACK (on_global_dropped), self, 0); + } } static void @@ -383,6 +440,29 @@ on_om_installed (WpObjectManager * om, WpStandardEventSource * self) wp_object_update_features (WP_OBJECT (self), WP_PLUGIN_FEATURE_ENABLED, 0); } +static void +on_global_om_installed (WpObjectManager * om, WpStandardEventSource * self) +{ + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + /* install object specific object managers */ + self->n_oms_installed = 0; + for (gint i = 0; i < N_OBJECT_TYPES; i++) { + GType gtype = object_type_to_gtype (i); + self->oms[i] = wp_object_manager_new (); + wp_object_manager_add_interest (self->oms[i], gtype, NULL); + wp_object_manager_request_object_features (self->oms[i], + gtype, WP_OBJECT_FEATURES_ALL); + g_signal_connect_object (self->oms[i], "object-added", + G_CALLBACK (on_object_added), self, 0); + g_signal_connect_object (self->oms[i], "object-removed", + G_CALLBACK (on_object_removed), self, 0); + g_signal_connect_object (self->oms[i], "installed", + G_CALLBACK (on_om_installed), self, 0); + wp_core_install_object_manager (core, self->oms[i]); + } +} + static void on_rescan_done (WpEvent * event, WpStandardEventSource * self) { @@ -408,22 +488,14 @@ wp_standard_event_source_enable (WpPlugin * plugin, WpTransition * transition) wp_event_dispatcher_get_instance (core); g_return_if_fail (dispatcher); - /* install object managers */ - self->n_oms_installed = 0; - for (gint i = 0; i < N_OBJECT_TYPES; i++) { - GType gtype = object_type_to_gtype (i); - self->oms[i] = wp_object_manager_new (); - wp_object_manager_add_interest (self->oms[i], gtype, NULL); - wp_object_manager_request_object_features (self->oms[i], - gtype, WP_OBJECT_FEATURES_ALL); - g_signal_connect_object (self->oms[i], "object-added", - G_CALLBACK (on_object_added), self, 0); - g_signal_connect_object (self->oms[i], "object-removed", - G_CALLBACK (on_object_removed), self, 0); - g_signal_connect_object (self->oms[i], "installed", - G_CALLBACK (on_om_installed), self, 0); - wp_core_install_object_manager (core, self->oms[i]); - } + /* Install global object manager */ + self->globals_om = wp_object_manager_new (); + wp_object_manager_add_interest (self->globals_om, WP_TYPE_GLOBAL_PROXY, NULL); + wp_object_manager_request_object_features (self->globals_om, + WP_TYPE_GLOBAL_PROXY, WP_PIPEWIRE_OBJECT_FEATURES_MINIMAL); + g_signal_connect_object (self->globals_om, "installed", + G_CALLBACK (on_global_om_installed), self, 0); + wp_core_install_object_manager (core, self->globals_om); /* install hook to restore the rescan_scheduled state just before rescanning */ self->rescan_done_hook = wp_simple_event_hook_new ( @@ -446,6 +518,7 @@ wp_standard_event_source_disable (WpPlugin * plugin) for (gint i = 0; i < N_OBJECT_TYPES; i++) g_clear_object (&self->oms[i]); + g_clear_object (&self->globals_om); if (dispatcher) wp_event_dispatcher_unregister_hook (dispatcher, self->rescan_done_hook); From 076dd5d4c17f2484c34b9da11dfb5d1a580d6f99 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 24 Apr 2026 11:28:39 -0400 Subject: [PATCH 05/12] global-proxy: Add _attach_collection() and _get_collection_name() APIs --- lib/wp/global-proxy.c | 116 ++++++++++++++++++++++++++++++++++++++++++ lib/wp/global-proxy.h | 9 ++++ 2 files changed, 125 insertions(+) diff --git a/lib/wp/global-proxy.c b/lib/wp/global-proxy.c index 046795aa..9b7fd8db 100644 --- a/lib/wp/global-proxy.c +++ b/lib/wp/global-proxy.c @@ -7,6 +7,7 @@ */ #include "global-proxy.h" +#include "collection.h" #include "private/registry.h" #include "core.h" #include "error.h" @@ -43,6 +44,8 @@ struct _WpGlobalProxyPrivate WpGlobal *global; gchar factory_name[96]; WpProperties *properties; + GWeakRef collection; + guint32 collected_id; }; enum { @@ -51,6 +54,7 @@ enum { PROP_FACTORY_NAME, PROP_GLOBAL_PROPERTIES, PROP_PERMISSIONS, + PROP_COLLECTION_NAME, }; G_DEFINE_TYPE_WITH_PRIVATE (WpGlobalProxy, wp_global_proxy, WP_TYPE_PROXY) @@ -58,6 +62,11 @@ G_DEFINE_TYPE_WITH_PRIVATE (WpGlobalProxy, wp_global_proxy, WP_TYPE_PROXY) static void wp_global_proxy_init (WpGlobalProxy * self) { + WpGlobalProxyPrivate *priv = + wp_global_proxy_get_instance_private (self); + + g_weak_ref_init (&priv->collection, NULL); + priv->collected_id = SPA_ID_INVALID; } static void @@ -67,6 +76,8 @@ wp_global_proxy_dispose (GObject * object) WpGlobalProxyPrivate *priv = wp_global_proxy_get_instance_private (self); + wp_global_proxy_attach_collection (self, NULL); + if (priv->global) wp_global_rm_flag (priv->global, WP_GLOBAL_FLAG_OWNED_BY_PROXY); @@ -80,6 +91,7 @@ wp_global_proxy_finalize (GObject * object) WpGlobalProxyPrivate *priv = wp_global_proxy_get_instance_private (self); + g_weak_ref_clear (&priv->collection); g_clear_pointer (&priv->properties, wp_properties_unref); g_clear_pointer (&priv->global, wp_global_unref); @@ -125,6 +137,9 @@ wp_global_proxy_get_property (GObject * object, guint property_id, case PROP_GLOBAL_PROPERTIES: g_value_take_boxed (value, wp_global_proxy_get_global_properties (self)); break; + case PROP_COLLECTION_NAME: + g_value_set_string (value, wp_global_proxy_get_collection_name (self)); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); break; @@ -250,6 +265,8 @@ wp_global_proxy_destroyed (WpProxy * proxy) WpGlobalProxyPrivate *priv = wp_global_proxy_get_instance_private (self); + wp_global_proxy_attach_collection (self, NULL); + if (priv->global && priv->global->proxy && (priv->global->flags & WP_GLOBAL_FLAG_OWNED_BY_PROXY)) { /* We can end up here as a result of _request_destroy() followed by @@ -308,6 +325,11 @@ wp_global_proxy_class_init (WpGlobalProxyClass * klass) g_param_spec_uint ("permissions", "permissions", "The pipewire global permissions", 0, G_MAXUINT, 0, G_PARAM_READABLE | G_PARAM_STATIC_STRINGS)); + + g_object_class_install_property (object_class, PROP_COLLECTION_NAME, + g_param_spec_string ("collection-name", "collection-name", + "The collection name this global belongs to", NULL, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS)); } /*! @@ -420,3 +442,97 @@ wp_global_proxy_bind (WpGlobalProxy * self) wp_proxy_set_pw_proxy (WP_PROXY (self), p); return TRUE; } + +static void +on_global_dropped (WpCollection *obj, guint32 global_id, + WpGlobalProxy *self) +{ + WpGlobalProxyPrivate *priv; + + priv = wp_global_proxy_get_instance_private (self); + + /* If this global was dropped from the collection, detach proxy from it */ + if (global_id == priv->collected_id) { + priv->collected_id = SPA_ID_INVALID; + g_weak_ref_set (&priv->collection, NULL); + } +} + +/*! + * \brief Attaches a collection in the global proxy + * + * \ingroup wpglobalproxy + * \param self the pipewire global + * \param collection (transfer none) (nullable): the collection to attach, or + * NULL to detach the current collection. + * \returns TRUE if the collection was attached and the global was collected, + * FALSE otherwise. + */ +gboolean +wp_global_proxy_attach_collection (WpGlobalProxy *self, + WpCollection *collection) +{ + WpGlobalProxyPrivate *priv; + g_autoptr (WpCollection) curr_collection = NULL; + + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (self), FALSE); + + priv = wp_global_proxy_get_instance_private (self); + + /* Dont do anything if this collection is already attached */ + curr_collection = g_weak_ref_get (&priv->collection); + if (curr_collection == collection) + return TRUE; + + /* Make sure the global proxy is bound before attaching a new collection */ + if (collection && + !(wp_object_get_active_features (WP_OBJECT (self)) & + WP_PROXY_FEATURE_BOUND)) { + wp_warning_object (self, + "global proxy %p is not bound, cannot attach collection", self); + return FALSE; + } + + /* Drop this global from the old collection if any */ + if (curr_collection) { + g_signal_handlers_disconnect_by_data (curr_collection, self); + wp_collection_drop_global (curr_collection, priv->collected_id); + priv->collected_id = SPA_ID_INVALID; + g_weak_ref_set (&priv->collection, NULL); + } + + /* Collect this global into the new collection if any */ + if (collection) { + priv->collected_id = wp_proxy_get_bound_id (WP_PROXY (self)); + g_weak_ref_set (&priv->collection, collection); + wp_collection_collect_global (collection, priv->collected_id); + g_signal_connect_object (collection, "global-dropped", + G_CALLBACK (on_global_dropped), self, 0); + } + + return TRUE; +} + +/*! + * \brief Gets the collection naem this global proxy is attached to + * + * \ingroup wpglobalproxy + * \param self the pipewire global + * \returns (nullable): the collection name this global proxy is attached to + */ +const gchar * +wp_global_proxy_get_collection_name (WpGlobalProxy *self) +{ + WpGlobalProxyPrivate *priv; + g_autoptr (WpCollection) c = NULL; + + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (self), NULL); + + priv = wp_global_proxy_get_instance_private (self); + + c = g_weak_ref_get (&priv->collection); + if (!c) + return NULL; + + return wp_collection_get_name (c); +} diff --git a/lib/wp/global-proxy.h b/lib/wp/global-proxy.h index 3bb82084..b96821ec 100644 --- a/lib/wp/global-proxy.h +++ b/lib/wp/global-proxy.h @@ -14,6 +14,8 @@ G_BEGIN_DECLS +typedef struct _WpCollection WpCollection; + /*! * \brief The WpGlobalProxy GType * \ingroup wpglobalproxy @@ -44,6 +46,13 @@ WpProperties * wp_global_proxy_get_global_properties ( WP_API gboolean wp_global_proxy_bind (WpGlobalProxy * self); +WP_API +gboolean wp_global_proxy_attach_collection (WpGlobalProxy *self, + WpCollection *collection); + +WP_API +const gchar * wp_global_proxy_get_collection_name (WpGlobalProxy *self); + G_END_DECLS #endif From 9d5c5c3fca5ce2d44140fa2fb9357d08afa3fc49 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 24 Apr 2026 11:50:33 -0400 Subject: [PATCH 06/12] m-lua-scripting: Add attach_collection(), get_collection_name() and get_global_properties() APIs for global proxies --- modules/module-lua-scripting/api/api.c | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/modules/module-lua-scripting/api/api.c b/modules/module-lua-scripting/api/api.c index 07186556..d948fdd4 100644 --- a/modules/module-lua-scripting/api/api.c +++ b/modules/module-lua-scripting/api/api.c @@ -568,6 +568,15 @@ static const luaL_Reg proxy_methods[] = { /* WpGlobalProxy */ +static int +global_proxy_get_global_properties (lua_State *L) +{ + WpGlobalProxy * p = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + WpProperties * props = wp_global_proxy_get_global_properties (p); + wplua_pushboxed (L, WP_TYPE_PROPERTIES, props); + return 1; +} + static int global_proxy_request_destroy (lua_State *L) { @@ -576,8 +585,29 @@ global_proxy_request_destroy (lua_State *L) return 0; } +static int +global_proxy_attach_collection (lua_State *L) +{ + WpGlobalProxy * p = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + WpCollection * c = luaL_opt (L, wplua_toobject, 2, NULL); + wp_global_proxy_attach_collection (p, c); + return 0; +} + +static int +global_proxy_get_collection_name (lua_State *L) +{ + WpGlobalProxy * p = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + const gchar *collection_name = wp_global_proxy_get_collection_name (p); + lua_pushstring (L, collection_name); + return 1; +} + static const luaL_Reg global_proxy_methods[] = { + { "get_global_properties", global_proxy_get_global_properties }, { "request_destroy", global_proxy_request_destroy }, + { "attach_collection", global_proxy_attach_collection }, + { "get_collection_name", global_proxy_get_collection_name }, { NULL, NULL } }; From 638f86ed01e1d3031c40e5a314c6d64edf3bd8bf Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 17 Dec 2025 12:10:08 -0500 Subject: [PATCH 07/12] create-item.lua: Handle nodes that are collected into collections This change makes sure the session items are always updated even after nodes are collected or dropped from a collection. We also set a 'collection.name' property so we can easily filter nodes by collection. --- src/scripts/node/create-item.lua | 64 ++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/src/scripts/node/create-item.lua b/src/scripts/node/create-item.lua index 259ebc83..28e12b4f 100644 --- a/src/scripts/node/create-item.lua +++ b/src/scripts/node/create-item.lua @@ -17,6 +17,7 @@ function configProperties (node) local properties = node.properties local media_class = properties ["media.class"] or "" local factory_name = properties ["factory.name"] or "" + local collection_name = node:get_collection_name () -- ensure a media.type is set if not properties ["media.type"] then @@ -44,6 +45,7 @@ function configProperties (node) (factory_name == "api.alsa.pcm.sink" or factory_name == "api.bluez5.a2dp.sink") and Settings.get_boolean ("node.features.audio.mono") properties ["node.id"] = node ["bound-id"] + properties ["collection.name"] = collection_name -- set the default media.role, if configured -- avoid Settings.get_string(), as it will parse the default "null" value @@ -61,69 +63,75 @@ AsyncEventHook { name = "node/create-item", interests = { EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Stream/*", type = "pw-global" }, }, EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Video/*", type = "pw-global" }, }, EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Audio/*", type = "pw-global" }, Constraint { "wireplumber.is-virtual", "-", type = "pw" }, }, }, steps = { start = { - next = "register", + next = "none", 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'] + local node_name = node.properties ['node.name'] + local collection_name = node:get_collection_name () + + -- Just return if the item already exists with the same collection name + if items [id] and + items [id]:get_property ("collection.name") == collection_name then + transition:advance () + return + end + + -- Destroy the old item if any + if items [id] then + log:info (items [id], "destroying item for node " .. tostring (id)) + items [id]:remove () + items [id] = nil + end + + -- get the item type + local item_type = nil 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) + local item = SessionItem (item_type) items [id] = item + log:info (item, "created item for node " .. tostring (id) .. ": " .. node_name) -- configure item if not item:configure (configProperties (node)) then - transition:return_error ("failed to configure item for node " - .. tostring (id)) + 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 () + transition:return_error ("failed to activate item for node " .. tostring (id) .. + ": " .. tostring (e)); + return 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 () + -- register item + log:info (item, "activated item for node " .. tostring (id) .. ": " .. node_name) + item:register () + transition:advance () + end) end, }, }, From 718c79668ec4c5ce817e1dd7fb283cb871992a5c Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 13:13:03 -0500 Subject: [PATCH 08/12] scripts: Add 'device/create-alsa-loopback.lua' script This new script allows creating loopback filters for ALSA nodes matching a particular route using a JSON configuration file. See the provided example configuration file for more info. --- src/config/wireplumber.conf | 5 + .../wireplumber.conf.d.examples/alsa.conf | 68 +++ src/scripts/device/create-alsa-loopback.lua | 518 ++++++++++++++++++ 3 files changed, 591 insertions(+) create mode 100644 src/scripts/device/create-alsa-loopback.lua diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index cfdf0577..03d57ea2 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -557,10 +557,15 @@ wireplumber.components = [ name = device/autoswitch-bluetooth-profile.lua, type = script/lua provides = hooks.device.profile.autoswitch-bluetooth } + { + name = device/create-alsa-loopback.lua, type = script/lua + provides = hooks.device.profile.create-alsa-loopback + } { type = virtual, provides = policy.device.profile requires = [ hooks.device.profile.select, hooks.device.profile.autoswitch-bluetooth, + hooks.device.profile.create-alsa-loopback, hooks.device.profile.apply ] wants = [ hooks.device.profile.find-voice-call, hooks.device.profile.find-best, diff --git a/src/config/wireplumber.conf.d.examples/alsa.conf b/src/config/wireplumber.conf.d.examples/alsa.conf index 4eed6d66..d2930da1 100644 --- a/src/config/wireplumber.conf.d.examples/alsa.conf +++ b/src/config/wireplumber.conf.d.examples/alsa.conf @@ -129,3 +129,71 @@ monitor.alsa.rules = [ # } # } ] + +loopback.alsa.rules = [ + ## The list of ALSA loopback rules + + ## This rule example allows creating a loopback for the route device 3 of + ## all ALSA devices. + # { + # matches = [ + # { + # ## This matches all ALSA cards + # device.name = "~alsa_card.*" + # } + # ] + # actions = { + # ## MANDATORY: This action is used to create a loopback for a particular + # ## route device number. An ALSA loopback collection for this route + # ## device will also be created so that it has its own policy. + # create-loopback = { + # ## This creates a loopback for sub-device 3, which is the ALSA node + # ## that represents the route with device number 3. + # [ + # ## MANDATORY: The route device this loopback will be used with + # route-device = 3 + # + # ## OPTIONAL: Whether the loopback is dynamic or not. Dynamic loopbacks + # ## are only available if the current ALSA profile supports the given + # ## route device. + # dynamic = false + # + # ## OPTIONAL: Extra ALSA loopback node properties + # device-node-props = { + # card.profile.device = 3 + # node.virtual = false + # priority.session = 1000 + # } + # + # ## OPTIONAL: Extra stream loopback node properties + # stream-node-props = { + # node.virtual = false + # } + # ] + # } + # + # ## OPTIONAL: This action is used to collect extra nodes into the ALSA + # ## loopback collection, which can be useful if we want to include extra + # ## filter nodes. + # collect-nodes = [ + # { + # matches = [ + # { + # ## This matches an ALSA device filter node + # node.name = "filter-evice-node-name" + # } + # { + # ## This matches an ALSA stream filter node + # node.name = "filter-stream-node-name" + # } + # ] + # actions = { + # ## MANDATORY: This action is used to specify where to collect the + # ## matched nodes + # select-route-device = 3 + # } + # } + # ] + # } + # } +] diff --git a/src/scripts/device/create-alsa-loopback.lua b/src/scripts/device/create-alsa-loopback.lua new file mode 100644 index 00000000..ab26918a --- /dev/null +++ b/src/scripts/device/create-alsa-loopback.lua @@ -0,0 +1,518 @@ +-- WirePlumber +-- +-- Copyright © 2025 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +-- This collect-global.lua script collects globals into collections. + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +config = {} +config.rules = Conf.get_section_as_json ("loopback.alsa.rules", Json.Array {}) + +local alsa_loopback_ids = {} +local alsa_loopbacks = {} +local impl_collections = {} + +function getProfileLoopbackIds (profile) + local loopback_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 + loopback_ids [dev_id + 1] = true + end + end + end + end + end + end + return loopback_ids +end + +AsyncEventHook { + name = "device/evaluate-loopbacks", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "=", "Profile" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local source = event:get_source () + local device = event:get_subject () + local create_loopback_info = nil + local loopback_info = {} + + -- Check if there are matching rules for this device + JsonUtils.match_rules (config.rules, device.properties, function (action, value) + if action == "create-loopback" then + create_loopback_info = value:parse () + end + return true + end) + if create_loopback_info == nil then + transition:advance () + return + end + + -- Populate the loopback_info table from the create_loopback_info table + for _, info in pairs(create_loopback_info) do + local id = info ["route-device"] + if id ~= nil and tonumber (id) >= 0 then + loopback_info [tonumber (id) + 1] = {} + loopback_info [tonumber (id) + 1].dynamic = info ["dynamic"] or false + loopback_info [tonumber (id) + 1].device_node_props = info ["device-node-props"] or {} + loopback_info [tonumber (id) + 1].stream_node_props = info ["stream-node-props"] or {} + else + log:warning (device, + "Found create loopback info without valid route-device property. Ignoring...") + end + end + + -- Get routes information + device:enum_params ("EnumRoute", function (enum_route_it, e) + -- check for error + if e then + transition:return_error ("failed to enum routes: " + .. tostring (e)); + return + end + + -- Make sure the device is still valid + if (device:get_active_features() & Feature.Proxy.BOUND) == 0 then + transition:advance () + return + end + + -- Get device routes info + local routes_info = {} + for p in enum_route_it:iterate() do + local route = cutils.parseParam (p, "EnumRoute") + if not route then + goto skip_enum_route + end + + for _, id in ipairs(route.devices) do + if routes_info [id + 1] == nil then + routes_info [id + 1] = {} + end + routes_info [id + 1].available = route.available + routes_info [id + 1].priority = route.priority + routes_info [id + 1].direction = route.direction + routes_info [id + 1].name = route.name + routes_info [id + 1].media_class = + route.direction == "Input" and "Audio/Source" or "Audio/Sink" + end + + ::skip_enum_route:: + end + + -- Make sure we found route info for all configured loopbacks + local all_loopback_info_found = true + for id, _ in pairs(loopback_info) do + if routes_info [id] == nil then + all_loopback_info_found = false + break + end + end + if not all_loopback_info_found then + transition:return_error ( + "Could not find route info for configured ALSA loopback IDs"); + return + end + + -- Get the current profile + local profile = nil + for p in device:iterate_params ("Profile") do + profile = cutils.parseParam (p, "Profile") + break + end + assert (profile) + + -- Get current and old loopback Ids + local new_loopback_ids = getProfileLoopbackIds (profile) + local old_loopback_ids = alsa_loopback_ids [device.id] ~= nil and + alsa_loopback_ids [device.id] or {} + alsa_loopback_ids [device.id] = new_loopback_ids + + -- Create dynamic loopbacks + for id, _ in ipairs(new_loopback_ids) do + local info = loopback_info [id] + if old_loopback_ids [id] == nil and info ~= nil and info.dynamic then + local e = source:call ("create-event", "create-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + EventDispatcher.push_event (e) + end + end + + -- Destroy dynamic loopbacks + for id, _ in ipairs(old_loopback_ids) do + local info = loopback_info [id] + if new_loopback_ids [id] == nil and info ~= nil and info.dynamic then + local e = source:call ("create-event", "destroy-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + EventDispatcher.push_event (e) + end + end + + -- Create static loopbacks if never created before + for id, info in pairs(loopback_info) do + if not info.dynamic and + (alsa_loopbacks [device.id] == nil or alsa_loopbacks [device.id][id] == nil) then + local e = source:call ("create-event", "create-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + EventDispatcher.push_event (e) + end + end + + transition:advance () + end) + end + } + } +}:register () + +function CreateLoopback (device, device_media_class, loopback_id, conf_device_node_props, conf_stream_node_props) + local devide_id = device["bound-id"] + local device_props = device.properties + local loopback_name = device_props ["device.name"] .. "." .. tostring (loopback_id) + local stream_media_class = nil + local args = nil + + -- Set stream media class + if device_media_class == "Audio/Sink" then + stream_media_class = "Stream/Output/Audio" + elseif device_media_class == "Audio/Source" then + stream_media_class = "Stream/Input/Audio" + else + log:warning (device, "Device media class '" .. device_media_class .. + "' is not valid") + return nil + end + + -- Set stream props + local stream_raw_props = { + ["node.name"] = string.format ("alsa_loopback_stream.%s", loopback_name), + ["node.description"] = string.format ("ALSA Loopback stream for %s", loopback_name), + ["media.class"] = stream_media_class, + ["alsa.loopback.id"] = loopback_id, + ["device.id"] = devide_id, + ["node.passive"] = true, + ["node.dont-fallback"] = true, + ["node.linger"] = true, + } + for k, v in pairs (conf_stream_node_props) do + stream_raw_props [k] = v + end + local stream_props = Json.Object (stream_raw_props) + + -- Set device props + local devices_raw_props = { + ["node.name"] = string.format ("alsa_loopback_device.%s", loopback_name), + ["node.description"] = string.format ("ALSA Loopback device for %s", loopback_name), + ["media.class"] = device_media_class, + ["alsa.loopback.id"] = loopback_id, + ["device.id"] = devide_id, + } + for k, v in pairs (conf_device_node_props) do + devices_raw_props [k] = v + end + local device_props = Json.Object (devices_raw_props) + + -- Set args + if device_media_class == "Audio/Sink" then + args = Json.Object { + ["playback.props"] = stream_props, + ["capture.props"] = device_props + } + elseif device_media_class == "Audio/Source" then + args = Json.Object { + ["playback.props"] = device_props, + ["capture.props"] = stream_props + } + else + return nil + end + + -- Load loopback + return LocalModule("libpipewire-module-loopback", args:get_data(), {}) +end + +function getCollectionName (device_name, loopback_id) + return "alsa_loopback." .. device_name .. "." .. tostring (loopback_id) +end + +AsyncEventHook { + name = "device/create-loopback", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-loopback" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local source = event:get_source () + local device = event:get_subject () + local device_name = device:get_property ("device.name") + + -- Get the loopback ID + local loopback_id = event:get_data ("loopback-id") + if loopback_id == nil then + transition:return_error ("Event data does not have loopback ID"); + return + end + + -- Get the media class property + local media_class = event:get_data ("media-class") + if media_class == nil then + transition:return_error ("Event data does not have media class property"); + return + end + + -- Get the device properties + local device_node_props = event:get_data ("device-node-props") + if device_node_props == nil then + transition:return_error ("Event data does not have device properties"); + return + end + + -- Get the stream properties + local stream_node_props = event:get_data ("stream-node-props") + if stream_node_props == nil then + transition:return_error ("Event data does not have stream properties"); + return + end + + -- Create the collection for this loopback + local collection_name = getCollectionName (device_name, loopback_id) + + -- Create the collection + if impl_collections [device.id] == nil then + impl_collections [device.id] = {} + end + local collection = impl_collections [device.id][collection_name] + if collection == nil then + impl_collections [device.id][collection_name] = ImplCollection (collection_name, { + ["device.id"] = device["bound-id"], + ["alsa.loopback.id"] = loopback_id, + }) + impl_collections [device.id][collection_name]:activate (Features.ALL, function (c, e) + -- Make sure the collection was created + if e ~= nil then + transition:return_error ("Failed to create collection '" .. + collection_name .. "': " .. tostring (e)); + return + end + log:info (device, "Created ALSA loopback collection for ID " .. + tostring (loopback_id) .. ": " .. collection_name .. " " .. tostring (c["bound-id"])) + + -- Create the loopback module + if alsa_loopbacks [device.id] == nil then + alsa_loopbacks [device.id] = {} + end + alsa_loopbacks [device.id][loopback_id] = CreateLoopback (device, + media_class, loopback_id, device_node_props, stream_node_props) + log:info (device, "Created loopback module for ID " .. + tostring (loopback_id)) + + transition:advance () + end) + else + log:warning (device, + "Collection '" .. collection_name .. "' already exists") + transition:advance () + end + end, + }, + }, +}:register () + +SimpleEventHook { + name = "device/destroy-loopback", + interests = { + EventInterest { + Constraint { "event.type", "=", "destroy-loopback" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + execute = function (event) + local device = event:get_subject () + + -- Get the loopback ID + local loopback_id = event:get_data ("loopback-id") + if loopback_id == nil then + log:warning (device, "Event data does not have loopback ID") + return + end + + -- Destroy loopback module + if alsa_loopbacks [device.id] == nil then + alsa_loopbacks [device.id] = {} + end + alsa_loopbacks [device.id][loopback_id] = nil + + -- Deactivate and destroy loopback collection + local collection_name = getCollectionName (device_name, loopback_id) + if impl_collections [device.id] ~= nil and + impl_collections [device.id][collection_name] then + impl_collections [device.id][collection_name]:deactivate(Features.ALL) + impl_collections [device.id][collection_name] = nil + end + + log:info (device, "Destroyed loopback module and collection for ID " .. + tostring (loopback_id)) + end +}:register () + +SimpleEventHook { + name = "device/destroy-loopbacks", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-removed" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + execute = function (event) + local device = event:get_subject () + local device_name = device:get_property ("device.name") + + -- Remove all loopbacks associated with this device + alsa_loopbacks [device.id] = nil + + -- Deactivate and destroy all collections associated with this device + if impl_collections [device.id] ~= nil then + for _, collection in ipairs(impl_collections [device.id]) do + collection:deactivate(Features.ALL) + end + impl_collections [device.id] = nil + end + end +}:register () + + +function evaluateCollectionForNode (collection, collect_node_rules, devices_om, node) + local collection_name = collection:get_name () + local collection_props = collection:get_global_properties () + local collection_device_id = collection_props:get_int ("device.id") + local collection_loopback_id = collection_props:get_int ("alsa.loopback.id") + local node_name = node:get_property ("node.name") + + -- Collect the node if there is a rule for it + JsonUtils.match_rules (collect_node_rules, node.properties, function (action, value) + if action == "select-route-device" and value:is_int () and value:parse () == collection_loopback_id then + node:attach_collection (collection) + log:info (collection, "Collected node '" .. node_name .. "' into '" .. + collection_name .. "'") + end + end) + if node:get_collection_name () ~= nil then + return + end + + -- Otherwise check if it is a matching ALSA or loopback node + + -- Never collect the ALSA loopback device node + local link_group = node:get_property ("node.link-group") + local media_class = node:get_property ("media.class") + if link_group ~= nil and link_group:find ("^loopback") and + not media_class:find ("^Stream/") then + return + end + + -- Get the node device Id + local device_id = node.properties:get_int ("device.id") + if device_id == nil then + return + end + + -- Get the node loopback ID + local loopback_id = node.properties:get_int ("card.profile.device") + if loopback_id == nil then + loopback_id = node.properties:get_int ("alsa.loopback.id") + if loopback_id == nil then + return + end + end + + -- Skip nodes with unmatched device_id or loopback_id + if loopback_id ~= collection_loopback_id or device_id ~= collection_device_id then + return + end + + -- Collect the node into the device collection + node:attach_collection (collection) + log:info (collection, "Collected node '" .. node_name .. "' into '" .. + collection_name .. "'") + +end + +SimpleEventHook { + name = "device/collection-added", + interests = { + EventInterest { + Constraint { "event.type", "=", "collection-added" }, + Constraint { "collection.name", "#", "alsa_loopback.*" }, + }, + }, + execute = function (event) + local source = event:get_source () + local collection = event:get_subject () + local collection_name = collection:get_name () + local collection_props = collection:get_global_properties () + local device_id = collection_props:get_int ("device.id") + + -- Get the associated device + local devices_om = source:call ("get-object-manager", "device") + local device = devices_om:lookup { + Constraint { "bound-id", "=", device_id, type = "gobject" }, + } + if device == nil then + return + end + + -- Check if there are matching rules for this device + local collect_node_rules = Json.Array {} + JsonUtils.match_rules (config.rules, device.properties, function (action, value) + if action == "collect-nodes" then + collect_node_rules = value + end + return true + end) + + -- Re-evaluate all nodes + local nodes_om = source:call ("get-object-manager", "node") + for node in nodes_om:iterate () do + evaluateCollectionForNode (collection, collect_node_rules, devices_om, node) + end + + end +}:register () From 7981fa2b2d7aeda51834f2e38eac9423739c668f Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 14:34:08 -0500 Subject: [PATCH 09/12] scripts/default-nodes: Restrict to nodes that are not part of any collection Nodes that are part of a collection are handled separately. --- src/scripts/default-nodes/apply-default-node.lua | 5 ++++- src/scripts/default-nodes/find-selected-default-node.lua | 5 ++++- src/scripts/default-nodes/rescan.lua | 6 +++++- src/scripts/default-nodes/state-default-nodes.lua | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/scripts/default-nodes/apply-default-node.lua b/src/scripts/default-nodes/apply-default-node.lua index 608118b7..56ec8d77 100644 --- a/src/scripts/default-nodes/apply-default-node.lua +++ b/src/scripts/default-nodes/apply-default-node.lua @@ -23,7 +23,10 @@ SimpleEventHook { local selected_node = event:get_data ("selected-node") local om = source:call ("get-object-manager", "metadata") - local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } } + local metadata = om:lookup { + Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, + } if metadata == nil then return end diff --git a/src/scripts/default-nodes/find-selected-default-node.lua b/src/scripts/default-nodes/find-selected-default-node.lua index 8ece52cd..e9b60d12 100644 --- a/src/scripts/default-nodes/find-selected-default-node.lua +++ b/src/scripts/default-nodes/find-selected-default-node.lua @@ -37,7 +37,10 @@ SimpleEventHook { 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 metadata = metadata_om:lookup { + Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, + } local obj = metadata:find (0, "default.configured." .. def_node_type) if not obj then diff --git a/src/scripts/default-nodes/rescan.lua b/src/scripts/default-nodes/rescan.lua index 5ac30c1b..98a2bd09 100644 --- a/src/scripts/default-nodes/rescan.lua +++ b/src/scripts/default-nodes/rescan.lua @@ -20,11 +20,13 @@ SimpleEventHook { Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, Constraint { "event.session-item.interface", "=", "linkable" }, Constraint { "media.class", "#", "Audio/*" }, + Constraint { "collection.name", "-" }, }, EventInterest { Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, Constraint { "event.session-item.interface", "=", "linkable" }, Constraint { "media.class", "#", "Video/*" }, + Constraint { "collection.name", "-" }, }, EventInterest { Constraint { "event.type", "=", "metadata-changed" }, @@ -32,6 +34,7 @@ SimpleEventHook { Constraint { "event.subject.key", "c", "default.configured.audio.sink", "default.configured.audio.source", "default.configured.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, EventInterest { Constraint { "event.type", "=", "device-params-changed"}, @@ -96,13 +99,14 @@ function collectAvailableNodes (si_om, devices_om, port_direction, media_classes for linkable in si_om:iterate { type = "SiLinkable", Constraint { "media.class", "c", table.unpack (media_classes) }, + Constraint { "collection.name", "-" }, } do local node = linkable:get_associated_proxy ("node") local node_props = node.properties -- check that the node has ports in the requested direction if not node:lookup_port { - Constraint { "port.direction", "=", port_direction } + Constraint { "port.direction", "=", port_direction }, } then goto next_linkable end diff --git a/src/scripts/default-nodes/state-default-nodes.lua b/src/scripts/default-nodes/state-default-nodes.lua index 1f379daf..2fd021b7 100644 --- a/src/scripts/default-nodes/state-default-nodes.lua +++ b/src/scripts/default-nodes/state-default-nodes.lua @@ -73,6 +73,7 @@ store_configured_default_nodes_hook = SimpleEventHook { Constraint { "event.subject.key", "c", "default.configured.audio.sink", "default.configured.audio.source", "default.configured.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) @@ -119,6 +120,7 @@ metadata_added_hook = SimpleEventHook { EventInterest { Constraint { "event.type", "=", "metadata-added" }, Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) From bbd2f89289f4204cf22757fb7f83ae032907a36b Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 14:35:39 -0500 Subject: [PATCH 10/12] filter-utils.lua: Restrict smart filters to nodes that are not part of any collection Smart filters are not meant to be used with collection specific policy. --- src/scripts/lib/filter-utils.lua | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/scripts/lib/filter-utils.lua b/src/scripts/lib/filter-utils.lua index 3a638468..1e185f98 100644 --- a/src/scripts/lib/filter-utils.lua +++ b/src/scripts/lib/filter-utils.lua @@ -129,7 +129,10 @@ local function getFilterSmartTarget (metadata, node, om) -- Find target local target = nil - for si_target in om:iterate { type = "SiLinkable" } do + for si_target in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local n_target = si_target:get_associated_proxy ("node") if n_target == nil then goto skip_target @@ -291,7 +294,10 @@ local function rescanFilters (om, metadata_om) Log.info ("rescanning filters...") - for si in om:iterate { type = "SiLinkable" } do + for si in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local filter = {} local n = si:get_associated_proxy ("node") @@ -337,7 +343,8 @@ local function rescanFilters (om, metadata_om) filter.stream_si = om:lookup { type = "SiLinkable", Constraint { "node.link-group", "=", filter.link_group }, - Constraint { "media.class", "#", "Stream/*", type = "pw-global" } + Constraint { "media.class", "#", "Stream/*", type = "pw-global" }, + Constraint { "collection.name", "-" }, } -- Add the filter to the list sorted by before and after From c81cd4cd96fffa2eadfa1bffd3886afc3cbc1563 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 15 Jan 2026 12:31:33 -0500 Subject: [PATCH 11/12] Restrict the policy to session items that are not part of any collection This will avoid conflics with collection specific policies. The policy scripts have also been moved inside the 'default' sub-directory. --- src/config/wireplumber.conf | 48 +++++++++---------- .../{ => default}/find-audio-group-target.lua | 2 + .../{ => default}/find-best-target.lua | 2 + .../{ => default}/find-default-target.lua | 1 + .../{ => default}/find-defined-target.lua | 7 ++- .../{ => default}/find-filter-target.lua | 1 + .../find-media-role-sink-target.lua | 18 ++++--- .../{ => default}/find-media-role-target.lua | 2 + .../find-user-target.lua.example | 1 + .../{ => default}/get-filter-from-target.lua | 1 + tests/script-tester.c | 12 ++--- 11 files changed, 57 insertions(+), 38 deletions(-) rename src/scripts/linking/{ => default}/find-audio-group-target.lua (96%) rename src/scripts/linking/{ => default}/find-best-target.lua (97%) rename src/scripts/linking/{ => default}/find-default-target.lua (97%) rename src/scripts/linking/{ => default}/find-defined-target.lua (95%) rename src/scripts/linking/{ => default}/find-filter-target.lua (98%) rename src/scripts/linking/{ => default}/find-media-role-sink-target.lua (84%) rename src/scripts/linking/{ => default}/find-media-role-target.lua (95%) rename src/scripts/linking/{ => default}/find-user-target.lua.example (95%) rename src/scripts/linking/{ => default}/get-filter-from-target.lua (98%) diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index 03d57ea2..9f86cc19 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -687,36 +687,36 @@ wireplumber.components = [ requires = [ hooks.linking.rescan ] } { - name = linking/find-media-role-target.lua, type = script/lua - provides = hooks.linking.target.find-media-role + name = linking/default/find-media-role-target.lua, type = script/lua + provides = hooks.linking.default.target.find-media-role } { - name = linking/find-defined-target.lua, type = script/lua - provides = hooks.linking.target.find-defined + name = linking/default/find-defined-target.lua, type = script/lua + provides = hooks.linking.default.target.find-defined } { - name = linking/find-audio-group-target.lua, type = script/lua - provides = hooks.linking.target.find-audio-group + name = linking/default/find-audio-group-target.lua, type = script/lua + provides = hooks.linking.default.target.find-audio-group requires = [ node.audio-group ] } { - name = linking/find-filter-target.lua, type = script/lua - provides = hooks.linking.target.find-filter + name = linking/default/find-filter-target.lua, type = script/lua + provides = hooks.linking.default.target.find-filter requires = [ metadata.filters ] } { - name = linking/find-default-target.lua, type = script/lua - provides = hooks.linking.target.find-default + name = linking/default/find-default-target.lua, type = script/lua + provides = hooks.linking.default.target.find-default requires = [ api.default-nodes ] } { - name = linking/find-best-target.lua, type = script/lua - provides = hooks.linking.target.find-best + name = linking/default/find-best-target.lua, type = script/lua + provides = hooks.linking.default.target.find-best requires = [ metadata.filters ] } { - name = linking/get-filter-from-target.lua, type = script/lua - provides = hooks.linking.target.get-filter-from + name = linking/default/get-filter-from-target.lua, type = script/lua + provides = hooks.linking.default.target.get-filter-from requires = [ metadata.filters ] } { @@ -740,13 +740,13 @@ wireplumber.components = [ hooks.linking.target.prepare-link, hooks.linking.target.link ] wants = [ hooks.linking.rescan-on-linkable, - hooks.linking.target.find-media-role, - hooks.linking.target.find-defined, - hooks.linking.target.find-audio-group, - hooks.linking.target.find-filter, - hooks.linking.target.find-default, - hooks.linking.target.find-best, - hooks.linking.target.get-filter-from, + hooks.linking.default.target.find-media-role, + hooks.linking.default.target.find-defined, + hooks.linking.default.target.find-audio-group, + hooks.linking.default.target.find-filter, + hooks.linking.default.target.find-default, + hooks.linking.default.target.find-best, + hooks.linking.default.target.get-filter-from, hooks.linking.pause-playback ] } @@ -762,15 +762,15 @@ wireplumber.components = [ requires = [ hooks.linking.role-based.rescan ] } { - name = linking/find-media-role-sink-target.lua, type = script/lua - provides = hooks.linking.target.find-media-role-sink + name = linking/default/find-media-role-sink-target.lua, type = script/lua + provides = hooks.linking.default.target.find-media-role-sink } { type = virtual, provides = policy.linking.role-based requires = [ policy.linking.standard, hooks.linking.role-based.rescan, hooks.node.role-based.default-volume, - hooks.linking.target.find-media-role-sink ] + hooks.linking.default.target.find-media-role-sink ] } ## Standard policy definition diff --git a/src/scripts/linking/find-audio-group-target.lua b/src/scripts/linking/default/find-audio-group-target.lua similarity index 96% rename from src/scripts/linking/find-audio-group-target.lua rename to src/scripts/linking/default/find-audio-group-target.lua index ffd2e182..3233439d 100644 --- a/src/scripts/linking/find-audio-group-target.lua +++ b/src/scripts/linking/default/find-audio-group-target.lua @@ -18,6 +18,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -56,6 +57,7 @@ SimpleEventHook { Constraint { "item.node.type", "=", "device" }, Constraint { "item.node.direction", "=", target_direction }, Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "-" }, } do target_node = target:get_associated_proxy ("node") target_node_props = target_node.properties diff --git a/src/scripts/linking/find-best-target.lua b/src/scripts/linking/default/find-best-target.lua similarity index 97% rename from src/scripts/linking/find-best-target.lua rename to src/scripts/linking/default/find-best-target.lua index 46b31507..4cc332ed 100644 --- a/src/scripts/linking/find-best-target.lua +++ b/src/scripts/linking/default/find-best-target.lua @@ -21,6 +21,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -46,6 +47,7 @@ SimpleEventHook { Constraint { "item.node.type", "=", "device" }, Constraint { "item.node.direction", "=", target_direction }, Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "-" }, } do local target_props = target.properties local target_node_id = target_props ["node.id"] diff --git a/src/scripts/linking/find-default-target.lua b/src/scripts/linking/default/find-default-target.lua similarity index 97% rename from src/scripts/linking/find-default-target.lua rename to src/scripts/linking/default/find-default-target.lua index 3caaf4f6..ce721f15 100644 --- a/src/scripts/linking/find-default-target.lua +++ b/src/scripts/linking/default/find-default-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/find-defined-target.lua b/src/scripts/linking/default/find-defined-target.lua similarity index 95% rename from src/scripts/linking/find-defined-target.lua rename to src/scripts/linking/default/find-defined-target.lua index 1dfb6d4b..dcd53e43 100644 --- a/src/scripts/linking/find-defined-target.lua +++ b/src/scripts/linking/default/find-defined-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -75,12 +76,16 @@ SimpleEventHook { target = om:lookup { type = "SiLinkable", Constraint { target_key, "=", target_value }, + Constraint { "collection.name", "-" }, } 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 + for lnkbl in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local target_props = lnkbl.properties if (target_props ["node.name"] == target_value or target_props ["object.path"] == target_value) and diff --git a/src/scripts/linking/find-filter-target.lua b/src/scripts/linking/default/find-filter-target.lua similarity index 98% rename from src/scripts/linking/find-filter-target.lua rename to src/scripts/linking/default/find-filter-target.lua index b82879ca..719648c0 100644 --- a/src/scripts/linking/find-filter-target.lua +++ b/src/scripts/linking/default/find-filter-target.lua @@ -38,6 +38,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/find-media-role-sink-target.lua b/src/scripts/linking/default/find-media-role-sink-target.lua similarity index 84% rename from src/scripts/linking/find-media-role-sink-target.lua rename to src/scripts/linking/default/find-media-role-sink-target.lua index d6ab4c38..2b22543b 100644 --- a/src/scripts/linking/find-media-role-sink-target.lua +++ b/src/scripts/linking/default/find-media-role-sink-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -45,6 +46,7 @@ SimpleEventHook { type = "SiLinkable", Constraint { "media.class", "=", "Audio/Sink" }, Constraint { "node.link-group", "=", link_group }, + Constraint { "collection.name", "-" }, } if input_node == nil then @@ -62,20 +64,22 @@ SimpleEventHook { type = "SiLinkable", Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, Constraint { "node.name", "=", target_name }, + Constraint { "collection.name", "-" }, } if si_target == nil then - si_target = om:lookup { - type = "SiLinkable", - Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, - Constraint { "node.nick", "=", target_name }, - } + si_target = om:lookup { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "node.nick", "=", target_name }, + Constraint { "collection.name", "-" }, + } end if si_target then - log:info (si, + log:info (si, string.format ("... role based sink target picked: %s (%s)", tostring (si_target.properties ["node.name"]), tostring (si_target.properties ["node.id"]))) - event:set_data ("target", si_target) + event:set_data ("target", si_target) end end }:register () diff --git a/src/scripts/linking/find-media-role-target.lua b/src/scripts/linking/default/find-media-role-target.lua similarity index 95% rename from src/scripts/linking/find-media-role-target.lua rename to src/scripts/linking/default/find-media-role-target.lua index d654b0c2..4c0682a8 100644 --- a/src/scripts/linking/find-media-role-target.lua +++ b/src/scripts/linking/default/find-media-role-target.lua @@ -18,6 +18,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -41,6 +42,7 @@ SimpleEventHook { Constraint { "item.node.direction", "=", target_direction }, Constraint { "device.intended-roles", "+" }, Constraint { "media.type", "=", si_props["media.type"] }, + Constraint { "collection.name", "-" }, } do local roles_json = si_target.properties["device.intended-roles"] diff --git a/src/scripts/linking/find-user-target.lua.example b/src/scripts/linking/default/find-user-target.lua.example similarity index 95% rename from src/scripts/linking/find-user-target.lua.example rename to src/scripts/linking/default/find-user-target.lua.example index 723913c5..8f0836fd 100644 --- a/src/scripts/linking/find-user-target.lua.example +++ b/src/scripts/linking/default/find-user-target.lua.example @@ -15,6 +15,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/get-filter-from-target.lua b/src/scripts/linking/default/get-filter-from-target.lua similarity index 98% rename from src/scripts/linking/get-filter-from-target.lua rename to src/scripts/linking/default/get-filter-from-target.lua index 8f52191c..f98951f4 100644 --- a/src/scripts/linking/get-filter-from-target.lua +++ b/src/scripts/linking/default/get-filter-from-target.lua @@ -22,6 +22,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/tests/script-tester.c b/tests/script-tester.c index e89e76b9..75b9d2d4 100644 --- a/tests/script-tester.c +++ b/tests/script-tester.c @@ -240,12 +240,12 @@ load_components (ScriptRunnerFixture *f, gconstpointer argv) load_component (f, "node/create-item.lua", "script/lua"); - load_component (f, "linking/find-best-target.lua", "script/lua"); - load_component (f, "linking/find-default-target.lua", "script/lua"); - load_component (f, "linking/find-defined-target.lua", "script/lua"); - load_component (f, "linking/find-filter-target.lua", "script/lua"); - load_component (f, "linking/find-media-role-target.lua", "script/lua"); - load_component (f, "linking/get-filter-from-target.lua", "script/lua"); + load_component (f, "linking/default/find-best-target.lua", "script/lua"); + load_component (f, "linking/default/find-default-target.lua", "script/lua"); + load_component (f, "linking/default/find-defined-target.lua", "script/lua"); + load_component (f, "linking/default/find-filter-target.lua", "script/lua"); + load_component (f, "linking/default/find-media-role-target.lua", "script/lua"); + load_component (f, "linking/default/get-filter-from-target.lua", "script/lua"); load_component (f, "linking/link-target.lua", "script/lua"); load_component (f, "linking/prepare-link.lua", "script/lua"); load_component (f, "linking/rescan.lua", "script/lua"); From dfeb57934d76275af6318cef2aa00068b89e3901 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 17 Dec 2025 09:38:15 -0500 Subject: [PATCH 12/12] linking: Add ALSA loopback policy scripts This policy script is in charge of linking all nodes from all ALSA loopback collections. --- src/config/wireplumber.conf | 10 ++ .../alsa-loopback/find-alsa-target.lua | 102 ++++++++++++++++++ .../alsa-loopback/find-filter-target.lua | 97 +++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 src/scripts/linking/alsa-loopback/find-alsa-target.lua create mode 100644 src/scripts/linking/alsa-loopback/find-filter-target.lua diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index 9f86cc19..fa9633c2 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -719,6 +719,14 @@ wireplumber.components = [ provides = hooks.linking.default.target.get-filter-from requires = [ metadata.filters ] } + { + name = linking/alsa-loopback/find-filter-target.lua, type = script/lua + provides = hooks.linking.alsa-loopback.target.find-filter + } + { + name = linking/alsa-loopback/find-alsa-target.lua, type = script/lua + provides = hooks.linking.alsa-loopback.target.find-alsa + } { name = linking/prepare-link.lua, type = script/lua provides = hooks.linking.target.prepare-link @@ -747,6 +755,8 @@ wireplumber.components = [ hooks.linking.default.target.find-default, hooks.linking.default.target.find-best, hooks.linking.default.target.get-filter-from, + hooks.linking.alsa-loopback.target.find-filter, + hooks.linking.alsa-loopback.target.find-alsa, hooks.linking.pause-playback ] } diff --git a/src/scripts/linking/alsa-loopback/find-alsa-target.lua b/src/scripts/linking/alsa-loopback/find-alsa-target.lua new file mode 100644 index 00000000..051ee561 --- /dev/null +++ b/src/scripts/linking/alsa-loopback/find-alsa-target.lua @@ -0,0 +1,102 @@ +-- WirePlumber +-- +-- Copyright © 2025 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Finds the hardware target for a linkable in 'alsa_loopback.*' collection + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/alsa-loopback/find-alsa-target", + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "#", "alsa_loopback.*" }, + }, + }, + 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 + + -- Get the collection name + local collection_name = si_props["collection.name"] + assert (collection_name) + + local target_direction = cutils.getTargetDirection (si_props) + local target_picked = nil + local target_can_passthrough = false + local target_priority = 0 + + log:info (string.format ("handling item %d: %s (%s)", si.id, + si_props ["node.name"], si_props ["node.id"])) + + -- Find the hardware target (session items without node.link-group property) + -- matching the collection name + 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"] }, + Constraint { "collection.name", "=", collection_name }, + Constraint { "node.link-group", "-" }, + } do + local target_props = target.properties + local priority = tonumber (target_props ["priority.session"]) or 0 + + log:debug (string.format ("Looking at: %s (%s)", + target_props ["node.name"], target_props ["node.id"])) + + if not lutils.canLink (si_props, target) then + log:debug ("... cannot link, 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 + + if target_picked == nil or priority > target_priority then + log:debug ("... picked") + target_picked = target + target_can_passthrough = can_passthrough + target_priority = priority + end + + ::skip_linkable:: + end + + 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 ("... hardware target picked: %s (%s), can_passthrough:%s", + target_picked.properties ["node.name"], + target_picked.properties ["node.id"], + tostring (target_can_passthrough))) + si_flags.can_passthrough = target_can_passthrough + event:set_data ("target", target_picked) + else + event:stop_processing () + end + end +}:register () diff --git a/src/scripts/linking/alsa-loopback/find-filter-target.lua b/src/scripts/linking/alsa-loopback/find-filter-target.lua new file mode 100644 index 00000000..2296efe8 --- /dev/null +++ b/src/scripts/linking/alsa-loopback/find-filter-target.lua @@ -0,0 +1,97 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Finds the filter target for a linkable in 'alsa_loopback.*' collection + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/alsa-loopback/find-filter-target", + before = "linking/alsa-loopback/find-alsa-target", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "#", "alsa_loopback.*" }, + }, + }, + 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 + + -- Get the collection name + local collection_name = si_props["collection.name"] + assert (collection_name) + + local target_direction = cutils.getTargetDirection (si_props) + local target_picked = nil + local target_can_passthrough = false + + log:info (string.format ("handling item %d: %s (%s)", si.id, + si_props ["node.name"], si_props ["node.id"])) + + -- Find the first filter target (session items with node.link-group property + -- that is not an alsa loopback item) matching the collection name + 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"] }, + Constraint { "collection.name", "=", collection_name }, + Constraint { "node.link-group", "+" }, + Constraint { "alsa.loopback.id", "-" }, + } do + local target_props = target.properties + + log:debug (string.format ("Looking at: %s (%s)", + target_props ["node.name"], target_props ["node.id"])) + + if not lutils.canLink (si_props, target) then + log:debug ("... cannot link, 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 + + log:debug ("... picked") + target_picked = target + target_can_passthrough = can_passthrough + break + + ::skip_linkable:: + end + + 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 ("... filter target picked: %s (%s), can_passthrough:%s", + target_picked.properties ["node.name"], + 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 ()