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/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 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/modules/module-lua-scripting/api/api.c b/modules/module-lua-scripting/api/api.c index 3b9dfdde..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 } }; @@ -2179,6 +2209,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 +3474,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, 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); diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index cfdf0577..fa9633c2 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, @@ -682,38 +687,46 @@ 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 ] } + { + 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 @@ -735,13 +748,15 @@ 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.alsa-loopback.target.find-filter, + hooks.linking.alsa-loopback.target.find-alsa, hooks.linking.pause-playback ] } @@ -757,15 +772,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/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/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) 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 () 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 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 () 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/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, }, }, 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", 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"); 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',