diff --git a/doc/media-session.dox b/doc/media-session.dox index 58183c40f..ed8c49dfa 100644 --- a/doc/media-session.dox +++ b/doc/media-session.dox @@ -30,6 +30,7 @@ List of Media Session modules: - \subpage page_media_session_module_alsa_endpoint - \subpage page_media_session_module_alsa_midi - \subpage page_media_session_module_alsa_monitor +- \subpage page_media_session_module_bluez_autoswitch - \subpage page_media_session_module_bluez_endpoint - \subpage page_media_session_module_bluez_monitor - \subpage page_media_session_module_default_nodes diff --git a/src/daemon/media-session.d/bluez-monitor.conf b/src/daemon/media-session.d/bluez-monitor.conf index 06ba5eb17..e610fd922 100644 --- a/src/daemon/media-session.d/bluez-monitor.conf +++ b/src/daemon/media-session.d/bluez-monitor.conf @@ -87,6 +87,12 @@ rules = [ # Profile connected first # Available values: a2dp-sink (default), headset-head-unit #device.profile = a2dp-sink + + # A2DP <-> HFP profile auto-switching (when device is default output) + # Available values: false, role (default), true + # 'role' will switch the profile if the recording application + # specifies Communication (or "phone" in PA) as the stream role. + #bluez5.autoswitch-profile = role } } } diff --git a/src/daemon/media-session.d/media-session.conf b/src/daemon/media-session.d/media-session.conf index c9a2471b0..92fda2d23 100644 --- a/src/daemon/media-session.d/media-session.conf +++ b/src/daemon/media-session.d/media-session.conf @@ -86,6 +86,7 @@ session.modules = { #alsa-seq # alsa seq midi support #alsa-monitor # alsa udev detection #bluez5 # bluetooth support + #bluez5-autoswitch # automatic bluetooth HSP/HFP profile switch #restore-stream # restore stream settings #logind # systemd-logind seat support ] @@ -106,6 +107,7 @@ session.modules = { with-pulseaudio = [ with-audio bluez5 + bluez5-autoswitch logind restore-stream streams-follow-default diff --git a/src/examples/media-session/bluez-autoswitch.c b/src/examples/media-session/bluez-autoswitch.c new file mode 100644 index 000000000..5f3c11f65 --- /dev/null +++ b/src/examples/media-session/bluez-autoswitch.c @@ -0,0 +1,613 @@ +/* PipeWire + * + * Copyright © 2021 Pauli Virtanen + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +#include +#include +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include "pipewire/pipewire.h" +#include "pipewire/extensions/metadata.h" + +#include "media-session.h" + +/** \page page_media_session_module_bluez_autoswitch Media Session Module: BlueZ Auto-Switch + * + * Switch profiles of Bluetooth devices trying to enable an input route, + * if input streams are active while default output is directed to + * the device. Profiles are restored once there are no active input streams. + * + * Not all input streams are considered, with behavior depending on + * configuration file settings. + */ + +#define NAME "bluez-autoswitch" +#define SESSION_KEY "bluez-autoswitch" + +#define RESTORE_DELAY_SEC 3 + +#define DEFAULT_AUDIO_SINK_KEY "default.audio.sink" + +struct impl { + struct sm_media_session *session; + struct spa_hook listener; + + struct spa_hook meta_listener; + + unsigned int record_count; + unsigned int communication_count; + + struct pw_context *context; + struct spa_source *restore_timeout; + + char *default_sink; + + struct pw_properties *old_profiles; + bool switched; +}; + +struct node { + struct sm_node *obj; + struct impl *impl; + struct spa_hook listener; + unsigned char active:1; + unsigned char communication:1; +}; + +struct find_data { + const char *type; + const char *name; + uint32_t id; + struct sm_object *obj; +}; + +static int find_check(void *data, struct sm_object *object) +{ + struct find_data *d = data; + + if (!spa_streq(object->type, d->type) || !object->props) + return 0; + + if (d->id != SPA_ID_INVALID && d->id == object->id) { + d->obj = object; + return 1; + } + + if (d->name != NULL && + spa_streq(pw_properties_get(object->props, PW_KEY_NODE_NAME), d->name)) { + d->obj = object; + return 1; + } + + return 0; +} + +static struct sm_object *find_by_name(struct impl *impl, const char *type, const char *name) +{ + struct find_data d = { type, name, SPA_ID_INVALID, NULL }; + if (name != NULL) + sm_media_session_for_each_object(impl->session, find_check, &d); + return d.obj; +} + +static struct sm_object *find_by_id(struct impl *impl, const char *type, uint32_t id) +{ + struct find_data d = { type, NULL, id, NULL }; + if (id != SPA_ID_INVALID) + sm_media_session_for_each_object(impl->session, find_check, &d); + return d.obj; +} + +static struct sm_device *find_default_output_device(struct impl *impl) +{ + struct sm_object *obj; + const char *str; + uint32_t device_id; + + if ((obj = find_by_name(impl, PW_TYPE_INTERFACE_Node, impl->default_sink)) == NULL || + !obj->props) + return NULL; + + if ((str = pw_properties_get(obj->props, PW_KEY_DEVICE_ID)) == NULL) + return NULL; + + if (!spa_atou32(str, &device_id, 10) || + (obj = find_by_id(impl, PW_TYPE_INTERFACE_Device, device_id)) == NULL) + return NULL; + + if (!spa_streq(obj->type, PW_TYPE_INTERFACE_Device) || !obj->props) + return NULL; + + return SPA_CONTAINER_OF(obj, struct sm_device, obj); +} + +static int find_profile(struct sm_device *dev, int32_t index, const char *name, + int32_t *out_index, const char **out_name, int32_t *out_priority) +{ + struct sm_param *p; + + spa_list_for_each(p, &dev->param_list, link) { + int32_t idx; + int32_t prio = 0; + const char *str; + + if (p->id != SPA_PARAM_EnumProfile || !p->param) + continue; + + if (spa_pod_parse_object(p->param, + SPA_TYPE_OBJECT_ParamProfile, NULL, + SPA_PARAM_PROFILE_index, SPA_POD_Int(&idx), + SPA_PARAM_PROFILE_name, SPA_POD_String(&str), + SPA_PARAM_PROFILE_priority, SPA_POD_OPT_Int(&prio)) < 0) + continue; + + if ((index < 0 || idx == index) && (name == NULL || spa_streq(str, name))) { + if (out_index) + *out_index = idx; + if (out_name) + *out_name = str; + if (out_priority) + *out_priority = prio; + return 0; + } + } + + return -ENOENT; +} + +static int set_profile(struct sm_device *dev, const char *profile_name) +{ + char buf[1024]; + struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + int32_t index = -1; + int ret; + + if (!profile_name) + return -EINVAL; + + if (!dev->obj.proxy) + return -ENOENT; + + if ((ret = find_profile(dev, -1, profile_name, &index, NULL, NULL)) < 0) + return ret; + + pw_log_info(NAME ": switching device %d to profile %s", dev->obj.id, profile_name); + + return pw_device_set_param((struct pw_device *)dev->obj.proxy, + SPA_PARAM_Profile, 0, + spa_pod_builder_add_object(&b, + SPA_TYPE_OBJECT_ParamProfile, SPA_PARAM_Profile, + SPA_PARAM_PROFILE_index, SPA_POD_Int(index))); +} + +static bool has_pending_restore(struct impl *impl) +{ + return (impl->old_profiles->dict.n_items > 0); +} + +static int do_restore_profile(void *data, struct sm_object *obj) +{ + struct impl *impl = data; + struct sm_device *dev; + const char *dev_name; + const char *profile_name; + + /* Find old profile and restore it */ + + if (!spa_streq(obj->type, PW_TYPE_INTERFACE_Device) || !obj->props) + goto next; + if ((dev_name = pw_properties_get(obj->props, PW_KEY_DEVICE_NAME)) == NULL) + goto next; + if ((profile_name = pw_properties_get(impl->old_profiles, dev_name)) == NULL) + goto next; + + dev = SPA_CONTAINER_OF(obj, struct sm_device, obj); + set_profile(dev, profile_name); + pw_properties_set(impl->old_profiles, dev_name, NULL); + +next: + return has_pending_restore(impl) ? 0 : 1; +} + +static void remove_restore_timeout(struct impl *impl) +{ + struct pw_loop *main_loop = pw_context_get_main_loop(impl->context); + if (impl->restore_timeout) { + pw_loop_destroy_source(main_loop, impl->restore_timeout); + impl->restore_timeout = NULL; + } +} + +static void restore_timeout(void *data, uint64_t expirations) +{ + struct impl *impl = data; + int res; + + remove_restore_timeout(impl); + + /* + * Switching profiles may make applications remove existing input streams + * and create new ones. To avoid getting into a rapidly spinning loop, + * restoring profiles has to be done with a timeout. + */ + + /* Restore previous profiles to devices */ + sm_media_session_for_each_object(impl->session, do_restore_profile, impl); + if ((res = sm_media_session_save_state(impl->session, SESSION_KEY, impl->old_profiles)) < 0) + pw_log_error("can't save "SESSION_KEY" state: %s", spa_strerror(res)); + + impl->switched = false; +} + +static void add_restore_timeout(struct impl *impl) +{ + struct timespec value; + struct pw_loop *main_loop = pw_context_get_main_loop(impl->context); + + if (!impl->switched) + return; + + if (impl->restore_timeout == NULL) + impl->restore_timeout = pw_loop_add_timer(main_loop, restore_timeout, impl); + + value.tv_sec = RESTORE_DELAY_SEC; + value.tv_nsec = 0; + pw_loop_update_timer(main_loop, impl->restore_timeout, &value, NULL, false); +} + +static void switch_profile_if_needed(struct impl *impl) +{ + struct sm_device *dev; + struct sm_param *p; + int headset_profile_priority = -1; + const char *current_profile_name = NULL; + const char *headset_profile_name = NULL; + enum spa_direction direction; + const char *dev_name; + const char *str; + int res; + + if (impl->record_count == 0) + goto inactive; + + pw_log_debug(NAME ": considering switching device profiles"); + + if ((dev = find_default_output_device(impl)) == NULL) + goto inactive; + + /* Handle only bluez devices */ + if (!spa_streq(pw_properties_get(dev->obj.props, PW_KEY_DEVICE_API), "bluez5")) + goto inactive; + if ((dev_name = pw_properties_get(dev->obj.props, PW_KEY_DEVICE_NAME)) == NULL) + goto inactive; + + /* Check autoswitch setting (default: role) */ + if ((str = pw_properties_get(dev->obj.props, "bluez5.autoswitch-profile")) == NULL) + str = "role"; + if (spa_atob(str)) { + /* ok */ + } else if (spa_streq(str, "role")) { + if (impl->communication_count == 0) + goto inactive; + } else { + goto inactive; + } + + /* BT microphone is wanted */ + + remove_restore_timeout(impl); + + if (pw_properties_get(impl->old_profiles, dev_name)) { + /* We already switched this device */ + return; + } + + /* Find current profile, and highest-priority profile with input route */ + spa_list_for_each(p, &dev->param_list, link) { + const char *name; + int32_t idx; + struct spa_pod *profiles = NULL; + + if (!p->param) + continue; + + switch (p->id) { + case SPA_PARAM_Route: + case SPA_PARAM_EnumRoute: + if (spa_pod_parse_object(p->param, + SPA_TYPE_OBJECT_ParamRoute, NULL, + SPA_PARAM_ROUTE_direction, SPA_POD_Id(&direction), + SPA_PARAM_ROUTE_profiles, SPA_POD_OPT_Pod(&profiles)) < 0) + continue; + if (direction != SPA_DIRECTION_INPUT) + continue; + + if (p->id == SPA_PARAM_Route) { + /* There's already an input route, no need to switch */ + return; + } else if (profiles) { + /* Take highest-priority (or first) profile in the input route */ + uint32_t *vals, n_vals, n; + vals = spa_pod_get_array(profiles, &n_vals); + if (vals == NULL) + continue; + for (n = 0; n < n_vals; ++n) { + int32_t i = vals[n]; + int32_t prio = -1; + const char *name = NULL; + if (find_profile(dev, i, NULL, NULL, &name, &prio) < 0) + continue; + if (headset_profile_priority < prio) { + headset_profile_priority = prio; + headset_profile_name = name; + } + } + } + break; + case SPA_PARAM_Profile: + if (spa_pod_parse_object(p->param, + SPA_TYPE_OBJECT_ParamProfile, NULL, + SPA_PARAM_PROFILE_index, SPA_POD_Int(&idx), + SPA_PARAM_PROFILE_name, SPA_POD_String(&name)) < 0) + continue; + current_profile_name = name; + break; + } + } + + if (set_profile(dev, headset_profile_name) < 0) + return; + + pw_properties_set(impl->old_profiles, dev_name, current_profile_name); + + if ((res = sm_media_session_save_state(impl->session, SESSION_KEY, impl->old_profiles)) < 0) + pw_log_error("can't save "SESSION_KEY" state: %s", spa_strerror(res)); + + impl->switched = true; + return; + +inactive: + add_restore_timeout(impl); + return; +} + +static void change_node_state(struct node *node, bool active, bool communication) +{ + bool need_switch = false; + struct impl *impl = node->impl; + + if (node->active != active) { + impl->record_count += active ? 1 : -1; + node->active = active; + need_switch = true; + } + + if (node->communication != communication) { + impl->communication_count += communication ? 1 : -1; + node->communication = communication; + need_switch = true; + } + + if (need_switch) + switch_profile_if_needed(impl); +} + +static void check_node(struct node *node) +{ + const char *str; + bool communication = false; + + if (!node->obj || !node->obj->obj.props || !node->obj->info || !node->obj->info->props) + goto inactive; + + if (!spa_streq(pw_properties_get(node->obj->obj.props, PW_KEY_MEDIA_CLASS), "Stream/Input/Audio")) + goto inactive; + + if ((str = spa_dict_lookup(node->obj->info->props, PW_KEY_NODE_AUTOCONNECT)) == NULL || + !spa_atob(str)) + goto inactive; + + if ((str = spa_dict_lookup(node->obj->info->props, PW_KEY_STREAM_MONITOR)) != NULL && + spa_atob(str)) + goto inactive; + + if (spa_streq(pw_properties_get(node->obj->obj.props, PW_KEY_MEDIA_ROLE), "Communication")) + communication = true; + + change_node_state(node, true, communication); + return; + +inactive: + change_node_state(node, false, false); +} + +static void object_update(void *data) +{ + struct node *node = data; + if (node->obj->obj.avail & (SM_NODE_CHANGE_MASK_PARAMS | SM_NODE_CHANGE_MASK_INFO)) + check_node(node); +} + +static const struct sm_object_events object_events = { + SM_VERSION_OBJECT_EVENTS, + .update = object_update +}; + +static void session_create(void *data, struct sm_object *object) +{ + struct impl *impl = data; + struct node *node; + + if (spa_streq(object->type, PW_TYPE_INTERFACE_Device) && has_pending_restore(impl)) { + impl->switched = true; + add_restore_timeout(impl); + return; + } + + if (!spa_streq(object->type, PW_TYPE_INTERFACE_Node) || !object->props) + return; + + if (!spa_streq(pw_properties_get(object->props, PW_KEY_MEDIA_CLASS), "Stream/Input/Audio")) + return; + + pw_log_debug(NAME ": input stream %d added", object->id); + + node = sm_object_add_data(object, SESSION_KEY, sizeof(struct node)); + if (!node->obj) { + node->obj = (struct sm_node *)object; + node->impl = impl; + sm_object_add_listener(&node->obj->obj, &node->listener, &object_events, node); + } + + check_node(node); +} + +static void session_remove(void *data, struct sm_object *object) +{ + struct node *node; + + if (!spa_streq(object->type, PW_TYPE_INTERFACE_Node)) + return; + + if ((node = sm_object_get_data(object, SESSION_KEY)) == NULL) + return; + + change_node_state(node, false, false); + + if (node->obj) { + pw_log_debug(NAME ": input stream %d removed", object->id); + spa_hook_remove(&node->listener); + node->obj = NULL; + } +} + +static void session_destroy(void *data) +{ + struct impl *impl = data; + remove_restore_timeout(impl); + spa_hook_remove(&impl->listener); + if (impl->session->metadata) + spa_hook_remove(&impl->meta_listener); + pw_properties_free(impl->old_profiles); + free(impl); +} + +static const struct sm_media_session_events session_events = { + SM_VERSION_MEDIA_SESSION_EVENTS, + .create = session_create, + .remove = session_remove, + .destroy = session_destroy, +}; + +static int json_object_find(const char *obj, const char *key, char *value, size_t len) +{ + struct spa_json it[2]; + const char *v; + char k[128]; + + spa_json_init(&it[0], obj, strlen(obj)); + if (spa_json_enter_object(&it[0], &it[1]) <= 0) + return -EINVAL; + + while (spa_json_get_string(&it[1], k, sizeof(k)-1) > 0) { + if (spa_streq(k, key)) { + if (spa_json_get_string(&it[1], value, len) <= 0) + continue; + return 0; + } else { + if (spa_json_next(&it[1], &v) <= 0) + break; + } + } + return -ENOENT; +} + +static int metadata_property(void *object, uint32_t subject, + const char *key, const char *type, const char *value) +{ + struct impl *impl = object; + if (subject == PW_ID_CORE) { + char *val = NULL; + char name[1024]; + + if (key && value && json_object_find(value, "name", name, sizeof(name)) < 0) + return 0; + else + val = name; + + if (key == NULL || spa_streq(key, DEFAULT_AUDIO_SINK_KEY)) { + free(impl->default_sink); + impl->default_sink = (key && val) ? strdup(val) : NULL; + + /* Switch also when default output changes */ + switch_profile_if_needed(impl); + } + } + return 0; +} + +static const struct pw_metadata_events metadata_events = { + PW_VERSION_METADATA_EVENTS, + .property = metadata_property, +}; + +int sm_bluez5_autoswitch_start(struct sm_media_session *session) +{ + struct impl *impl; + int res; + + impl = calloc(1, sizeof(struct impl)); + if (impl == NULL) + return -errno; + + impl->session = session; + impl->context = session->context; + + impl->old_profiles = pw_properties_new(NULL, NULL); + if (impl->old_profiles == NULL) { + free(impl); + return -ENOMEM; + } + + if ((res = sm_media_session_load_state(impl->session, SESSION_KEY, impl->old_profiles)) < 0) + pw_log_info("can't load "SESSION_KEY" state: %s", spa_strerror(res)); + + sm_media_session_add_listener(impl->session, &impl->listener, &session_events, impl); + + if (session->metadata) { + pw_metadata_add_listener(session->metadata, + &impl->meta_listener, + &metadata_events, impl); + } + + return 0; +} diff --git a/src/examples/media-session/media-session.c b/src/examples/media-session/media-session.c index fb82cf741..381c53786 100644 --- a/src/examples/media-session/media-session.c +++ b/src/examples/media-session/media-session.c @@ -94,6 +94,7 @@ int sm_alsa_midi_start(struct sm_media_session *sess); int sm_v4l2_monitor_start(struct sm_media_session *sess); int sm_libcamera_monitor_start(struct sm_media_session *sess); int sm_bluez5_monitor_start(struct sm_media_session *sess); +int sm_bluez5_autoswitch_start(struct sm_media_session *sess); int sm_alsa_monitor_start(struct sm_media_session *sess); int sm_suspend_node_start(struct sm_media_session *sess); #ifdef HAVE_SYSTEMD @@ -2388,6 +2389,7 @@ static const struct { { "v4l2", "video for linux udev detection", sm_v4l2_monitor_start, NULL }, { "libcamera", "libcamera udev detection", sm_libcamera_monitor_start, NULL }, { "bluez5", "bluetooth support", sm_bluez5_monitor_start, NULL }, + { "bluez5-autoswitch", "switch bluetooth profiles automatically", sm_bluez5_autoswitch_start, NULL }, { "suspend-node", "suspend inactive nodes", sm_suspend_node_start, NULL }, { "policy-node", "configure and link nodes", sm_policy_node_start, NULL }, { "pulse-bridge", "accept pulseaudio clients", sm_pulse_bridge_start, NULL }, diff --git a/src/examples/meson.build b/src/examples/meson.build index 8db75b348..23155efc8 100644 --- a/src/examples/meson.build +++ b/src/examples/meson.build @@ -71,6 +71,7 @@ if get_option('session-managers').contains('media-session') and alsa_dep.found() 'media-session/alsa-endpoint.c', 'media-session/bluez-monitor.c', 'media-session/bluez-endpoint.c', + 'media-session/bluez-autoswitch.c', 'media-session/default-nodes.c', 'media-session/default-profile.c', 'media-session/default-routes.c',