diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/audiomixer-service.cpp | 129 | ||||
-rw-r--r-- | src/audiomixer-service.hpp | 44 | ||||
-rw-r--r-- | src/audiomixer.c | 463 | ||||
-rw-r--r-- | src/audiomixer.h | 71 | ||||
-rw-r--r-- | src/main.cpp | 34 | ||||
-rw-r--r-- | src/meson.build | 18 | ||||
-rw-r--r-- | src/vis-config.cpp | 157 | ||||
-rw-r--r-- | src/vis-config.hpp | 43 | ||||
-rw-r--r-- | src/vis-session.cpp | 374 | ||||
-rw-r--r-- | src/vis-session.hpp | 78 |
10 files changed, 1411 insertions, 0 deletions
diff --git a/src/audiomixer-service.cpp b/src/audiomixer-service.cpp new file mode 100644 index 0000000..5787153 --- /dev/null +++ b/src/audiomixer-service.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "audiomixer-service.hpp" +#include <iostream> +#include <algorithm> + + +AudiomixerService::AudiomixerService(const VisConfig &config, net::io_context& ioc, ssl::context& ctx) : + VisSession(config, ioc, ctx) +{ + m_audiomixer = audiomixer_new(); + if (m_audiomixer) { + // Set up callbacks for WirePlumber events + m_audiomixer_events.controls_changed = audiomixer_control_change_cb; + m_audiomixer_events.value_changed = audiomixer_value_change_cb; + audiomixer_add_event_listener(m_audiomixer, &m_audiomixer_events, this); + + // Drive connecting to PipeWire core and refreshing controls list + audiomixer_lock(m_audiomixer); + audiomixer_ensure_controls(m_audiomixer, 3); + audiomixer_unlock(m_audiomixer); + } else { + std::cerr << "Could not create WirePlumber connection" << std::endl; + } +} + +AudiomixerService::~AudiomixerService() +{ + audiomixer_free(m_audiomixer); +} + +void AudiomixerService::handle_authorized_response(void) +{ + subscribe("Vehicle.Cabin.Infotainment.Media.Volume"); + subscribe("Vehicle.Cabin.SteeringWheel.Switches.VolumeUp"); + subscribe("Vehicle.Cabin.SteeringWheel.Switches.VolumeDown"); + subscribe("Vehicle.Cabin.SteeringWheel.Switches.VolumeMute"); + + // Set initial volume in VSS + // For now a value of 50 matches the default in the homescreen app. + // Ideally there would be some form of persistence scheme to restore + // the last value on restart. + set("Vehicle.Cabin.Infotainment.Media.Volume", "50"); +} + +void AudiomixerService::handle_get_response(std::string &path, std::string &value, std::string ×tamp) +{ + // Placeholder since no gets are performed ATM +} + +void AudiomixerService::handle_notification(std::string &path, std::string &value, std::string ×tamp) +{ + if (!m_audiomixer) { + return; + } + + audiomixer_lock(m_audiomixer); + + const struct mixer_control *ctl = audiomixer_find_control(m_audiomixer, "Master Playback"); + if (!ctl) { + audiomixer_unlock(m_audiomixer); + return; + } + + if (path == "Vehicle.Cabin.Infotainment.Media.Volume") { + try { + int volume = std::stoi(value); + if (volume >= 0 && volume <= 100) { + double v = (double) volume / 100.0; + if (m_config.verbose() > 1) + std::cout << "Setting volume to " << v << std::endl; + audiomixer_change_volume(m_audiomixer, ctl, v); + } + } + catch (std::exception ex) { + // ignore bad value + } + } else if (path == "Vehicle.Cabin.SteeringWheel.Switches.VolumeUp" && value == "true") { + double volume = ctl->volume; + volume += 0.05; // up 5% + if (volume > 1.0) + volume = 1.0; // clamp to 100% + if (m_config.verbose() > 1) + std::cout << "Increasing volume to " << volume << std::endl; + audiomixer_change_volume(m_audiomixer, ctl, volume); + + } else if (path == "Vehicle.Cabin.SteeringWheel.Switches.VolumeDown" && value == "true") { + double volume = ctl->volume; + volume -= 0.05; // down 5% + if (volume < 0.0) + volume = 0.0; // clamp to 0% + if (m_config.verbose() > 1) + std::cout << "Decreasing volume to " << volume << std::endl; + audiomixer_change_volume(m_audiomixer, ctl, volume); + + } else if (path == "Vehicle.Cabin.SteeringWheel.Switches.VolumeMute" && value == "true") { + if (m_config.verbose() > 1) { + if (ctl->mute) + std::cout << "Unmuting" << std::endl; + else + std::cout << "Muting" << std::endl; + } + audiomixer_change_mute(m_audiomixer, ctl, !ctl->mute); + } + // else ignore + + audiomixer_unlock(m_audiomixer); +} + +void AudiomixerService::handle_control_change(void) +{ + // Ignore for now +} + +void AudiomixerService::handle_value_change(unsigned int change_mask, const struct mixer_control *control) +{ + if (!control) + return; + + if (change_mask & MIXER_CONTROL_CHANGE_FLAG_VOLUME) { + if (std::string(control->name) == "Master Playback") { + // Push change into VIS + std::string value = std::to_string((int) (control->volume * 100.0)); + set("Vehicle.Cabin.Infotainment.Media.Volume", value); + } + } else if (change_mask & MIXER_CONTROL_CHANGE_FLAG_MUTE) { + // For now, do nothing, new state is in control->mute + } +} diff --git a/src/audiomixer-service.hpp b/src/audiomixer-service.hpp new file mode 100644 index 0000000..cb00584 --- /dev/null +++ b/src/audiomixer-service.hpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +#ifndef _AUDIOMIXER_SERVICE_HPP +#define _AUDIOMIXER_SERVICE_HPP + +#include "vis-session.hpp" +#include "audiomixer.h" + +class AudiomixerService : public VisSession +{ + struct audiomixer *m_audiomixer; + +public: + AudiomixerService(const VisConfig &config, net::io_context& ioc, ssl::context& ctx); + + ~AudiomixerService(); + + static void audiomixer_control_change_cb(void *data) { + if (data) + ((AudiomixerService*) data)->handle_control_change(); + }; + + static void audiomixer_value_change_cb(void *data, + unsigned int change_mask, + const struct mixer_control *control) { + if (data) + ((AudiomixerService*) data)->handle_value_change(change_mask, control); + } + +protected: + struct audiomixer_events m_audiomixer_events; + + virtual void handle_authorized_response(void) override; + + virtual void handle_get_response(std::string &path, std::string &value, std::string ×tamp) override; + + virtual void handle_notification(std::string &path, std::string &value, std::string ×tamp) override; + + virtual void handle_control_change(void); + + virtual void handle_value_change(unsigned int change_mask, const struct mixer_control *control); +}; + +#endif // _AUDIOMIXER_SERVICE_HPP diff --git a/src/audiomixer.c b/src/audiomixer.c new file mode 100644 index 0000000..a40a38e --- /dev/null +++ b/src/audiomixer.c @@ -0,0 +1,463 @@ +/* + * Copyright © 2019 Collabora Ltd. + * @author George Kiagiadakis <george.kiagiadakis@collabora.com> + * + * SPDX-License-Identifier: MIT + */ + +#include "audiomixer.h" +#include <wp/wp.h> +#include <pipewire/pipewire.h> + +struct audiomixer +{ + WpCore *core; + GMainLoop *loop; + GMainContext *context; + GThread *thread; + GMutex lock; + GCond cond; + + GPtrArray *mixer_controls; + + gint initialized; + WpObjectManager *om; + WpPlugin *default_nodes_api; + WpPlugin *mixer_api; + + const struct audiomixer_events *events; + void *events_data; +}; + +struct mixer_control_impl +{ + struct mixer_control pub; + guint32 node_id; +}; + +struct action +{ + struct audiomixer *audiomixer; + union { + struct { + guint32 id; + gfloat volume; + } change_volume; + struct { + guint32 id; + gboolean mute; + } change_mute; + }; +}; + +static gboolean +get_mixer_controls (struct audiomixer * self, guint32 node_id, gdouble * vol, gboolean * mute) +{ + g_autoptr (GVariant) v = NULL; + g_signal_emit_by_name (self->mixer_api, "get-volume", node_id, &v); + return v && + g_variant_lookup (v, "volume", "d", vol) && + g_variant_lookup (v, "mute", "b", mute); +} + +static void +add_control (struct audiomixer *self, const char *name, guint32 node_id) +{ + struct mixer_control_impl *mixctl = NULL; + gdouble volume = 1.0; + gboolean mute = FALSE; + + /* get current values */ + if (!get_mixer_controls (self, node_id, &volume, &mute)) { + g_warning ("failed to get object controls when populating controls"); + return; + } + + /* create the control */ + mixctl = g_new0 (struct mixer_control_impl, 1); + snprintf (mixctl->pub.name, sizeof (mixctl->pub.name), "%s", name); + mixctl->pub.volume = volume; + mixctl->pub.mute = mute; + mixctl->node_id = node_id; + g_ptr_array_add (self->mixer_controls, mixctl); + + g_debug ("added control %s", mixctl->pub.name); +} + +static void +volume_changed (struct audiomixer * self, guint32 node_id) +{ + g_autoptr (GMutexLocker) locker = g_mutex_locker_new (&self->lock); + gdouble vol = 1.0; + gboolean mute = FALSE; + + for (guint i = 0; i < self->mixer_controls->len; i++) { + struct mixer_control_impl *ctl; + guint change_mask = 0; + + ctl = g_ptr_array_index (self->mixer_controls, i); + if (ctl->node_id != node_id) + continue; + + if (!get_mixer_controls (self, node_id, &vol, &mute)) { + g_warning ("failed to get object controls when volume changed"); + return; + } + + if ((ctl->pub.volume - 0.01f) > vol || (ctl->pub.volume + 0.01f) < vol) { + ctl->pub.volume = vol; + change_mask |= MIXER_CONTROL_CHANGE_FLAG_VOLUME; + } + if (ctl->pub.mute != mute) { + ctl->pub.mute = mute; + change_mask |= MIXER_CONTROL_CHANGE_FLAG_MUTE; + } + + if (self->events && self->events->value_changed) + self->events->value_changed (self->events_data, change_mask, &ctl->pub); + break; + } +} + +static void +rescan_controls (struct audiomixer * self) +{ + g_autoptr (GMutexLocker) locker = g_mutex_locker_new (&self->lock); + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint32 id = -1; + + g_debug ("rescan"); + + /* clear previous */ + g_ptr_array_set_size (self->mixer_controls, 0); + + /* add master controls */ + g_signal_emit_by_name (self->default_nodes_api, "get-default-node", + "Audio/Sink", &id); + if (id != (guint32)-1) + add_control (self, "Master Playback", id); + + g_signal_emit_by_name (self->default_nodes_api, "get-default-node", + "Audio/Source", &id); + if (id != (guint32)-1) + add_control (self, "Master Capture", id); + + /* add endpoints */ + it = wp_object_manager_new_iterator (self->om); + for (; wp_iterator_next (it, &val); g_value_unset (&val)) { + WpPipewireObject *ep = g_value_get_object (&val); + const gchar *name = wp_pipewire_object_get_property (ep, "endpoint.description"); + const gchar *node = wp_pipewire_object_get_property (ep, "node.id"); + id = node ? atoi(node) : 0; + if (name && id != 0 && id != (guint32)-1) + add_control (self, name, id); + } + + /* notify subscribers */ + if (self->events && self->events->controls_changed) + self->events->controls_changed (self->events_data); + g_cond_broadcast (&self->cond); +} + +static void +on_default_nodes_activated (WpObject * p, GAsyncResult * res, struct audiomixer * self) +{ + g_autoptr (GError) error = NULL; + if (!wp_object_activate_finish (p, res, &error)) { + g_warning ("%s", error->message); + } + + if (wp_object_get_active_features (WP_OBJECT (self->mixer_api)) + & WP_PLUGIN_FEATURE_ENABLED) + wp_core_install_object_manager (self->core, self->om); + + g_signal_connect_swapped (self->default_nodes_api, "changed", + (GCallback) rescan_controls, self); +} + +static void +on_mixer_activated (WpObject * p, GAsyncResult * res, struct audiomixer * self) +{ + g_autoptr (GError) error = NULL; + if (!wp_object_activate_finish (p, res, &error)) { + g_warning ("%s", error->message); + } + + if (wp_object_get_active_features (WP_OBJECT (self->default_nodes_api)) + & WP_PLUGIN_FEATURE_ENABLED) + wp_core_install_object_manager (self->core, self->om); + + g_signal_connect_swapped (self->mixer_api, "changed", + (GCallback) volume_changed, self); +} + +static void +on_core_connected (struct audiomixer * self) +{ + self->om = wp_object_manager_new (); + wp_object_manager_add_interest (self->om, WP_TYPE_ENDPOINT, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + PW_KEY_MEDIA_CLASS, "#s", "Audio/*", NULL); + wp_object_manager_request_object_features (self->om, + WP_TYPE_ENDPOINT, WP_OBJECT_FEATURES_ALL); + g_signal_connect_swapped (self->om, "objects-changed", + (GCallback) rescan_controls, self); + + wp_object_activate (WP_OBJECT (self->default_nodes_api), + WP_PLUGIN_FEATURE_ENABLED, NULL, + (GAsyncReadyCallback) on_default_nodes_activated, self); + + wp_object_activate (WP_OBJECT (self->mixer_api), + WP_PLUGIN_FEATURE_ENABLED, NULL, + (GAsyncReadyCallback) on_mixer_activated, self); +} + +static void +on_core_disconnected (struct audiomixer * self) +{ + g_ptr_array_set_size (self->mixer_controls, 0); + g_clear_object (&self->om); + g_signal_handlers_disconnect_by_data (self->default_nodes_api, self); + g_signal_handlers_disconnect_by_data (self->mixer_api, self); + wp_object_deactivate (WP_OBJECT (self->default_nodes_api), WP_PLUGIN_FEATURE_ENABLED); + wp_object_deactivate (WP_OBJECT (self->mixer_api), WP_PLUGIN_FEATURE_ENABLED); +} + +static void +audiomixer_init_in_thread (struct audiomixer * self) +{ + g_autoptr (GMutexLocker) locker = g_mutex_locker_new (&self->lock); + g_autoptr (GError) error = NULL; + + self->context = g_main_context_new (); + g_main_context_push_thread_default (self->context); + + self->loop = g_main_loop_new (self->context, FALSE); + self->core = wp_core_new (self->context, NULL); + + /* load required API modules */ + if (!wp_core_load_component (self->core, + "libwireplumber-module-default-nodes-api", "module", NULL, &error)) { + g_warning ("%s", error->message); + self->initialized = -1; + goto out; + } + if (!wp_core_load_component (self->core, + "libwireplumber-module-mixer-api", "module", NULL, &error)) { + g_warning ("%s", error->message); + self->initialized = -1; + goto out; + } + + self->default_nodes_api = wp_plugin_find (self->core, "default-nodes-api"); + self->mixer_api = wp_plugin_find (self->core, "mixer-api"); + g_object_set (G_OBJECT (self->mixer_api), "scale", 1 /* cubic */, NULL); + + g_signal_connect_swapped (self->core, "connected", + G_CALLBACK (on_core_connected), self); + g_signal_connect_swapped (self->core, "disconnected", + G_CALLBACK (on_core_disconnected), self); + + self->initialized = 1; + +out: + g_cond_broadcast (&self->cond); +} + +static void * +audiomixer_thread (struct audiomixer * self) +{ + audiomixer_init_in_thread (self); + + /* main loop for the thread; quits only when audiomixer_free() is called */ + g_main_loop_run (self->loop); + + wp_core_disconnect (self->core); + g_clear_object (&self->default_nodes_api); + g_clear_object (&self->mixer_api); + g_object_unref (self->core); + + g_main_context_pop_thread_default (self->context); + g_main_loop_unref (self->loop); + g_main_context_unref (self->context); + + return NULL; +} + +struct audiomixer * +audiomixer_new (void) +{ + struct audiomixer *self = calloc(1, sizeof(struct audiomixer)); + + wp_init (WP_INIT_ALL); + + g_mutex_init (&self->lock); + g_cond_init (&self->cond); + self->mixer_controls = g_ptr_array_new_with_free_func (g_free); + + g_mutex_lock (&self->lock); + self->initialized = 0; + self->thread = g_thread_new ("audiomixer", (GThreadFunc) audiomixer_thread, + self); + while (self->initialized == 0) + g_cond_wait (&self->cond, &self->lock); + g_mutex_unlock (&self->lock); + + return self; +} + +void +audiomixer_free(struct audiomixer *self) +{ + g_main_loop_quit (self->loop); + g_thread_join (self->thread); + + g_ptr_array_unref (self->mixer_controls); + g_cond_clear (&self->cond); + g_mutex_clear (&self->lock); + + free (self); +} + +void +audiomixer_lock(struct audiomixer *self) +{ + g_mutex_lock (&self->lock); +} + +void +audiomixer_unlock(struct audiomixer *self) +{ + g_mutex_unlock (&self->lock); +} + +static gboolean +do_connect (WpCore * core) +{ + if (!wp_core_connect (core)) + g_warning ("Failed to connect to PipeWire"); + return G_SOURCE_REMOVE; +} + +int +audiomixer_ensure_controls(struct audiomixer *self, int timeout_sec) +{ + gint64 end_time = g_get_monotonic_time () + timeout_sec * G_TIME_SPAN_SECOND; + + g_return_val_if_fail (self->initialized == 1, -EIO); + + if (!wp_core_is_connected (self->core)) + g_main_context_invoke (self->context, (GSourceFunc) do_connect, self->core); + + while (self->mixer_controls->len == 0) { + if (!g_cond_wait_until (&self->cond, &self->lock, end_time)) + return -ETIMEDOUT; + } + return 0; +} + +const struct mixer_control ** +audiomixer_get_active_controls(struct audiomixer *self, + unsigned int *n_controls) +{ + *n_controls = self->mixer_controls->len; + return (const struct mixer_control **) self->mixer_controls->pdata; +} + +const struct mixer_control * +audiomixer_find_control(struct audiomixer *self, const char *name) +{ + struct mixer_control *ctl; + + for (guint i = 0; i < self->mixer_controls->len; i++) { + ctl = g_ptr_array_index (self->mixer_controls, i); + if (!strcmp(ctl->name, name)) { + return ctl; + } + } + return NULL; +} + +void +audiomixer_add_event_listener(struct audiomixer *self, + const struct audiomixer_events *events, + void *data) +{ + self->events = events; + self->events_data = data; +} + +static gboolean +do_change_volume (struct action * action) +{ + struct audiomixer *self = action->audiomixer; + GVariantBuilder b = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE_VARDICT); + gboolean ret = FALSE; + + g_variant_builder_add (&b, "{sv}", "volume", + g_variant_new_double (action->change_volume.volume)); + g_signal_emit_by_name (self->mixer_api, "set-volume", + action->change_volume.id, g_variant_builder_end (&b), &ret); + if (!ret) + g_warning ("mixer api set-volume failed"); + + return G_SOURCE_REMOVE; +} + +void +audiomixer_change_volume(struct audiomixer *self, + const struct mixer_control *control, + double volume) +{ + const struct mixer_control_impl *impl = + (const struct mixer_control_impl *) control; + struct action * action; + + g_return_if_fail (self->initialized == 1); + + /* schedule the action to run on the audiomixer thread */ + action = g_new0 (struct action, 1); + action->audiomixer = self; + action->change_volume.id = impl->node_id; + action->change_volume.volume = (gfloat) volume; + wp_core_idle_add (self->core, NULL, (GSourceFunc) do_change_volume, action, + g_free); +} + +static gboolean +do_change_mute (struct action * action) +{ + struct audiomixer *self = action->audiomixer; + GVariantBuilder b = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE_VARDICT); + gboolean ret = FALSE; + + g_variant_builder_add (&b, "{sv}", "mute", + g_variant_new_boolean (action->change_mute.mute)); + g_signal_emit_by_name (self->mixer_api, "set-volume", + action->change_mute.id, g_variant_builder_end (&b), &ret); + if (!ret) + g_warning ("mixer api set-volume failed"); + + return G_SOURCE_REMOVE; +} + +void +audiomixer_change_mute(struct audiomixer *self, + const struct mixer_control *control, + bool mute) +{ + const struct mixer_control_impl *impl = + (const struct mixer_control_impl *) control; + struct action * action; + + g_return_if_fail (self->initialized == 1); + + /* schedule the action to run on the audiomixer thread */ + action = g_new0 (struct action, 1); + action->audiomixer = self; + action->change_mute.id = impl->node_id; + action->change_mute.mute = mute; + wp_core_idle_add (self->core, NULL, (GSourceFunc) do_change_mute, action, + g_free); +} diff --git a/src/audiomixer.h b/src/audiomixer.h new file mode 100644 index 0000000..cc67a83 --- /dev/null +++ b/src/audiomixer.h @@ -0,0 +1,71 @@ +/* + * Copyright © 2019 Collabora Ltd. + * @author George Kiagiadakis <george.kiagiadakis@collabora.com> + * + * SPDX-License-Identifier: MIT + */ + +#ifndef _AUDIOMIXER_H +#define _AUDIOMIXER_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include <stdbool.h> + +struct audiomixer; + +struct mixer_control +{ + char name[32]; + double volume; + bool mute; +}; + +struct audiomixer_events +{ + void (*controls_changed) (void *data); + + void (*value_changed) (void *data, +#define MIXER_CONTROL_CHANGE_FLAG_VOLUME (1<<0) +#define MIXER_CONTROL_CHANGE_FLAG_MUTE (1<<1) + unsigned int change_mask, + const struct mixer_control *control); +}; + +struct audiomixer * audiomixer_new(void); +void audiomixer_free(struct audiomixer *self); + +/* locking is required to call any of the methods below + * and to access any structure maintained by audiomixer */ +void audiomixer_lock(struct audiomixer *self); +void audiomixer_unlock(struct audiomixer *self); + +int audiomixer_ensure_controls(struct audiomixer *self, int timeout_sec); + +const struct mixer_control ** audiomixer_get_active_controls( + struct audiomixer *self, + unsigned int *n_controls); + +const struct mixer_control * audiomixer_find_control( + struct audiomixer *self, + const char *name); + +void audiomixer_add_event_listener(struct audiomixer *self, + const struct audiomixer_events *events, + void *data); + +void audiomixer_change_volume(struct audiomixer *self, + const struct mixer_control *control, + double volume); + +void audiomixer_change_mute(struct audiomixer *self, + const struct mixer_control *control, + bool mute); + +#ifdef __cplusplus +} +#endif + +#endif // _AUDIOMIXER_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0960f1b --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include <iostream> +#include <iomanip> +#include <boost/asio/signal_set.hpp> +#include <boost/bind.hpp> +#include "audiomixer-service.hpp" + +using work_guard_type = boost::asio::executor_work_guard<boost::asio::io_context::executor_type>; + +int main(int argc, char** argv) +{ + // The io_context is required for all I/O + net::io_context ioc; + + // Register to stop I/O context on SIGINT and SIGTERM + net::signal_set signals(ioc, SIGINT, SIGTERM); + signals.async_wait(boost::bind(&net::io_context::stop, &ioc)); + + // The SSL context is required, and holds certificates + ssl::context ctx{ssl::context::tlsv12_client}; + + // Launch the asynchronous operation + VisConfig config("agl-service-audiomixer"); + std::make_shared<AudiomixerService>(config, ioc, ctx)->run(); + + // Ensure I/O context continues running even if there's no work + work_guard_type work_guard(ioc.get_executor()); + + // Run the I/O context + ioc.run(); + + return 0; +} diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000..282d130 --- /dev/null +++ b/src/meson.build @@ -0,0 +1,18 @@ +boost_dep = dependency('boost', + version : '>=1.72', + modules : [ 'thread', 'filesystem', 'program_options', 'log', 'system' ]) +openssl_dep = dependency('openssl') +thread_dep = dependency('threads') +wp_dep = dependency('wireplumber-0.4') + +src = [ 'vis-config.cpp', + 'vis-session.cpp', + 'audiomixer-service.cpp', + 'audiomixer.c', + 'main.cpp' +] +executable('agl-service-audiomixer', + src, + dependencies: [boost_dep, openssl_dep, thread_dep, systemd_dep, wp_dep], + install: true, + install_dir : get_option('sbindir')) diff --git a/src/vis-config.cpp b/src/vis-config.cpp new file mode 100644 index 0000000..b8d9266 --- /dev/null +++ b/src/vis-config.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "vis-config.hpp" +#include <iostream> +#include <iomanip> +#include <sstream> +#include <boost/property_tree/ptree.hpp> +#include <boost/property_tree/ini_parser.hpp> +#include <boost/filesystem.hpp> + +namespace property_tree = boost::property_tree; +namespace filesystem = boost::filesystem; + +#define DEFAULT_CLIENT_KEY_FILE "/etc/kuksa-val/Client.key" +#define DEFAULT_CLIENT_CERT_FILE "/etc/kuksa-val/Client.pem" +#define DEFAULT_CA_CERT_FILE "/etc/kuksa-val/CA.pem" + + +VisConfig::VisConfig(const std::string &hostname, + const unsigned port, + const std::string &clientKey, + const std::string &clientCert, + const std::string &caCert, + const std::string &authToken, + bool verifyPeer) : + m_hostname(hostname), + m_port(port), + m_clientKey(clientKey), + m_clientCert(clientCert), + m_caCert(caCert), + m_authToken(authToken), + m_verifyPeer(verifyPeer), + m_verbose(0), + m_valid(true) +{ + // Potentially could do some certificate validation here... +} + +VisConfig::VisConfig(const std::string &appname) : + m_valid(false) +{ + std::string config("/etc/xdg/AGL/"); + config += appname; + config += ".conf"; + char *home = getenv("XDG_CONFIG_HOME"); + if (home) { + config = home; + config += "/AGL/"; + config += appname; + config += ".conf"; + } + + std::cout << "Using configuration " << config << std::endl; + property_tree::ptree pt; + try { + property_tree::ini_parser::read_ini(config, pt); + } + catch (std::exception &ex) { + std::cerr << "Could not read " << config << std::endl; + return; + } + const property_tree::ptree &settings = + pt.get_child("vis-client", property_tree::ptree()); + + m_hostname = settings.get("server", "localhost"); + std::stringstream ss; + ss << m_hostname; + ss >> std::quoted(m_hostname); + if (m_hostname.empty()) { + std::cerr << "Invalid server hostname" << std::endl; + return; + } + + m_port = settings.get("port", 8090); + if (m_port == 0) { + std::cerr << "Invalid server port" << std::endl; + return; + } + + // Default to disabling peer verification for now to be able + // to use the default upstream KUKSA.val certificates for + // testing. Wrangling server and CA certificate generation + // and management to be able to verify will require further + // investigation. + m_verifyPeer = settings.get("verify-server", false); + + std::string keyFileName = settings.get("key", DEFAULT_CLIENT_KEY_FILE); + std::stringstream().swap(ss); + ss << keyFileName; + ss >> std::quoted(keyFileName); + ss.str(""); + if (keyFileName.empty()) { + std::cerr << "Invalid client key filename" << std::endl; + return; + } + filesystem::load_string_file(keyFileName, m_clientKey); + if (m_clientKey.empty()) { + std::cerr << "Invalid client key file" << std::endl; + return; + } + + std::string certFileName = settings.get("certificate", DEFAULT_CLIENT_CERT_FILE); + std::stringstream().swap(ss); + ss << certFileName; + ss >> std::quoted(certFileName); + if (certFileName.empty()) { + std::cerr << "Invalid client certificate filename" << std::endl; + return; + } + filesystem::load_string_file(certFileName, m_clientCert); + if (m_clientCert.empty()) { + std::cerr << "Invalid client certificate file" << std::endl; + return; + } + + std::string caCertFileName = settings.get("ca-certificate", DEFAULT_CA_CERT_FILE); + std::stringstream().swap(ss); + ss << caCertFileName; + ss >> std::quoted(caCertFileName); + if (caCertFileName.empty()) { + std::cerr << "Invalid CA certificate filename" << std::endl; + return; + } + filesystem::load_string_file(caCertFileName, m_caCert); + if (m_caCert.empty()) { + std::cerr << "Invalid CA certificate file" << std::endl; + return; + } + + std::string authTokenFileName = settings.get("authorization", ""); + std::stringstream().swap(ss); + ss << authTokenFileName; + ss >> std::quoted(authTokenFileName); + if (authTokenFileName.empty()) { + std::cerr << "Invalid authorization token filename" << std::endl; + return; + } + filesystem::load_string_file(authTokenFileName, m_authToken); + if (m_authToken.empty()) { + std::cerr << "Invalid authorization token file" << std::endl; + return; + } + + m_verbose = 0; + std::string verbose = settings.get("verbose", ""); + std::stringstream().swap(ss); + ss << verbose; + ss >> std::quoted(verbose); + if (!verbose.empty()) { + if (verbose == "true" || verbose == "1") + m_verbose = 1; + if (verbose == "2") + m_verbose = 2; + } + + m_valid = true; +} diff --git a/src/vis-config.hpp b/src/vis-config.hpp new file mode 100644 index 0000000..b0f72f9 --- /dev/null +++ b/src/vis-config.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +#ifndef _VIS_CONFIG_HPP +#define _VIS_CONFIG_HPP + +#include <string> + +class VisConfig +{ +public: + explicit VisConfig(const std::string &hostname, + const unsigned port, + const std::string &clientKey, + const std::string &clientCert, + const std::string &caCert, + const std::string &authToken, + bool verifyPeer = true); + explicit VisConfig(const std::string &appname); + ~VisConfig() {}; + + std::string hostname() { return m_hostname; }; + unsigned port() { return m_port; }; + std::string clientKey() { return m_clientKey; }; + std::string clientCert() { return m_clientCert; }; + std::string caCert() { return m_caCert; }; + std::string authToken() { return m_authToken; }; + bool verifyPeer() { return m_verifyPeer; }; + bool valid() { return m_valid; }; + unsigned verbose() { return m_verbose; }; + +private: + std::string m_hostname; + unsigned m_port; + std::string m_clientKey; + std::string m_clientCert; + std::string m_caCert; + std::string m_authToken; + bool m_verifyPeer; + unsigned m_verbose; + bool m_valid; +}; + +#endif // _VIS_CONFIG_HPP diff --git a/src/vis-session.cpp b/src/vis-session.cpp new file mode 100644 index 0000000..880e3ae --- /dev/null +++ b/src/vis-session.cpp @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "vis-session.hpp" +#include <iostream> +#include <sstream> +#include <thread> + + +// Logging helper +static void log_error(beast::error_code error, char const* what) +{ + std::cerr << what << " error: " << error.message() << std::endl; +} + + +// Resolver and socket require an io_context +VisSession::VisSession(const VisConfig &config, net::io_context& ioc, ssl::context& ctx) : + m_config(config), + m_resolver(net::make_strand(ioc)), + m_ws(net::make_strand(ioc), ctx) +{ +} + +// Start the asynchronous operation +void VisSession::run() +{ + if (!m_config.valid()) { + return; + } + + // Start by resolving hostname + m_resolver.async_resolve(m_config.hostname(), + std::to_string(m_config.port()), + beast::bind_front_handler(&VisSession::on_resolve, + shared_from_this())); +} + +void VisSession::on_resolve(beast::error_code error, + tcp::resolver::results_type results) +{ + if(error) { + log_error(error, "resolve"); + return; + } + + // Set a timeout on the connect operation + beast::get_lowest_layer(m_ws).expires_after(std::chrono::seconds(30)); + + // Connect to resolved address + if (m_config.verbose()) + std::cout << "Connecting" << std::endl; + m_results = results; + connect(); +} + +void VisSession::connect() +{ + beast::get_lowest_layer(m_ws).async_connect(m_results, + beast::bind_front_handler(&VisSession::on_connect, + shared_from_this())); +} + +void VisSession::on_connect(beast::error_code error, + tcp::resolver::results_type::endpoint_type endpoint) +{ + if(error) { + // The server can take a while to be ready to accept connections, + // so keep retrying until we hit the timeout. + if (error == net::error::timed_out) { + log_error(error, "connect"); + return; + } + + // Delay 500 ms before retrying + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + if (m_config.verbose()) + std::cout << "Connecting" << std::endl; + + connect(); + return; + } + + if (m_config.verbose()) + std::cout << "Connected" << std::endl; + + // Set handshake timeout + beast::get_lowest_layer(m_ws).expires_after(std::chrono::seconds(30)); + + // Set SNI Hostname (many hosts need this to handshake successfully) + if(!SSL_set_tlsext_host_name(m_ws.next_layer().native_handle(), + m_config.hostname().c_str())) + { + error = beast::error_code(static_cast<int>(::ERR_get_error()), + net::error::get_ssl_category()); + log_error(error, "connect"); + return; + } + + // Update the hostname. This will provide the value of the + // Host HTTP header during the WebSocket handshake. + // See https://tools.ietf.org/html/rfc7230#section-5.4 + m_hostname = m_config.hostname() + ':' + std::to_string(endpoint.port()); + + if (m_config.verbose()) + std::cout << "Negotiating SSL handshake" << std::endl; + + // Perform the SSL handshake + m_ws.next_layer().async_handshake(ssl::stream_base::client, + beast::bind_front_handler(&VisSession::on_ssl_handshake, + shared_from_this())); +} + +void VisSession::on_ssl_handshake(beast::error_code error) +{ + if(error) { + log_error(error, "SSL handshake"); + return; + } + + // Turn off the timeout on the tcp_stream, because + // the websocket stream has its own timeout system. + beast::get_lowest_layer(m_ws).expires_never(); + + // NOTE: Explicitly not setting websocket stream timeout here, + // as the client is long-running. + + if (m_config.verbose()) + std::cout << "Negotiating WSS handshake" << std::endl; + + // Perform handshake + m_ws.async_handshake(m_hostname, + "/", + beast::bind_front_handler(&VisSession::on_handshake, + shared_from_this())); +} + +void VisSession::on_handshake(beast::error_code error) +{ + if(error) { + log_error(error, "WSS handshake"); + return; + } + + if (m_config.verbose()) + std::cout << "Authorizing" << std::endl; + + // Authorize + json req; + req["requestId"] = std::to_string(m_requestid++); + req["action"]= "authorize"; + req["tokens"] = m_config.authToken(); + + m_ws.async_write(net::buffer(req.dump(4)), + beast::bind_front_handler(&VisSession::on_authorize, + shared_from_this())); +} + +void VisSession::on_authorize(beast::error_code error, std::size_t bytes_transferred) +{ + boost::ignore_unused(bytes_transferred); + + if(error) { + log_error(error, "authorize"); + return; + } + + // Read response + m_ws.async_read(m_buffer, + beast::bind_front_handler(&VisSession::on_read, + shared_from_this())); +} + +// NOTE: Placeholder for now +void VisSession::on_write(beast::error_code error, std::size_t bytes_transferred) +{ + boost::ignore_unused(bytes_transferred); + + if(error) { + log_error(error, "write"); + return; + } + + // Do nothing... +} + +void VisSession::on_read(beast::error_code error, std::size_t bytes_transferred) +{ + boost::ignore_unused(bytes_transferred); + + if(error) { + log_error(error, "read"); + return; + } + + // Handle message + std::string s = beast::buffers_to_string(m_buffer.data()); + json response = json::parse(s, nullptr, false); + if (!response.is_discarded()) { + handle_message(response); + } else { + std::cerr << "json::parse failed? got " << s << std::endl; + } + m_buffer.consume(m_buffer.size()); + + // Read next message + m_ws.async_read(m_buffer, + beast::bind_front_handler(&VisSession::on_read, + shared_from_this())); +} + +void VisSession::get(const std::string &path) +{ + if (!m_config.valid()) { + return; + } + + json req; + req["requestId"] = std::to_string(m_requestid++); + req["action"] = "get"; + req["path"] = path; + req["tokens"] = m_config.authToken(); + + m_ws.write(net::buffer(req.dump(4))); +} + +void VisSession::set(const std::string &path, const std::string &value) +{ + if (!m_config.valid()) { + return; + } + + json req; + req["requestId"] = std::to_string(m_requestid++); + req["action"] = "set"; + req["path"] = path; + req["value"] = value; + req["tokens"] = m_config.authToken(); + + m_ws.write(net::buffer(req.dump(4))); +} + +void VisSession::subscribe(const std::string &path) +{ + if (!m_config.valid()) { + return; + } + + json req; + req["requestId"] = std::to_string(m_requestid++); + req["action"] = "subscribe"; + req["path"] = path; + req["tokens"] = m_config.authToken(); + + m_ws.write(net::buffer(req.dump(4))); +} + +bool VisSession::parseData(const json &message, std::string &path, std::string &value, std::string ×tamp) +{ + if (message.contains("error")) { + std::string error = message["error"]; + return false; + } + + if (!(message.contains("data") && message["data"].is_object())) { + std::cerr << "Malformed message (data missing)" << std::endl; + return false; + } + auto data = message["data"]; + if (!(data.contains("path") && data["path"].is_string())) { + std::cerr << "Malformed message (path missing)" << std::endl; + return false; + } + path = data["path"]; + // Convert '/' to '.' in paths to ensure consistency for clients + std::replace(path.begin(), path.end(), '/', '.'); + + if (!(data.contains("dp") && data["dp"].is_object())) { + std::cerr << "Malformed message (datapoint missing)" << std::endl; + return false; + } + auto dp = data["dp"]; + if (!dp.contains("value")) { + std::cerr << "Malformed message (value missing)" << std::endl; + return false; + } else if (dp["value"].is_string()) { + value = dp["value"]; + } else if (dp["value"].is_number_float()) { + double num = dp["value"]; + value = std::to_string(num); + } else if (dp["value"].is_boolean()) { + value = dp["value"] ? "true" : "false"; + } else { + std::cerr << "Malformed message (unsupported value type)" << std::endl; + return false; + } + + if (!(dp.contains("ts") && dp["ts"].is_string())) { + std::cerr << "Malformed message (timestamp missing)" << std::endl; + return false; + } + timestamp = dp["ts"]; + + return true; +} + +void VisSession::handle_message(const json &message) +{ + if (m_config.verbose() > 1) + std::cout << "VisSession::handle_message: enter, message = " << to_string(message) << std::endl; + + if (!message.contains("action")) { + std::cerr << "Received unknown message (no action), discarding" << std::endl; + return; + } + + std::string action = message["action"]; + if (action == "authorize") { + if (message.contains("error")) { + std::string error = "unknown"; + if (message["error"].is_object() && message["error"].contains("message")) + error = message["error"]["message"]; + std::cerr << "VIS authorization failed: " << error << std::endl; + } else { + if (m_config.verbose() > 1) + std::cout << "authorized" << std::endl; + + handle_authorized_response(); + } + } else if (action == "subscribe") { + if (message.contains("error")) { + std::string error = "unknown"; + if (message["error"].is_object() && message["error"].contains("message")) + error = message["error"]["message"]; + std::cerr << "VIS subscription failed: " << error << std::endl; + } + } else if (action == "get") { + if (message.contains("error")) { + std::string error = "unknown"; + if (message["error"].is_object() && message["error"].contains("message")) + error = message["error"]["message"]; + std::cerr << "VIS get failed: " << error << std::endl; + } else { + std::string path, value, ts; + if (parseData(message, path, value, ts)) { + if (m_config.verbose() > 1) + std::cout << "VisSession::handle_message: got response " << path << " = " << value << std::endl; + + handle_get_response(path, value, ts); + } + } + } else if (action == "set") { + if (message.contains("error")) { + std::string error = "unknown"; + if (message["error"].is_object() && message["error"].contains("message")) + error = message["error"]["message"]; + std::cerr << "VIS set failed: " << error; + } + } else if (action == "subscription") { + std::string path, value, ts; + if (parseData(message, path, value, ts)) { + if (m_config.verbose() > 1) + std::cout << "VisSession::handle_message: got notification " << path << " = " << value << std::endl; + + handle_notification(path, value, ts); + } + } else { + std::cerr << "unhandled VIS response of type: " << action; + } + + if (m_config.verbose() > 1) + std::cout << "VisSession::handle_message: exit" << std::endl; +} + diff --git a/src/vis-session.hpp b/src/vis-session.hpp new file mode 100644 index 0000000..8c7b0d9 --- /dev/null +++ b/src/vis-session.hpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +#ifndef _VIS_SESSION_HPP +#define _VIS_SESSION_HPP + +#include "vis-config.hpp" +#include <atomic> +#include <string> +#include <boost/beast/core.hpp> +#include <boost/beast/ssl.hpp> +#include <boost/beast/websocket.hpp> +#include <boost/beast/websocket/ssl.hpp> +#include <boost/asio/strand.hpp> +#include <nlohmann/json.hpp> + +namespace beast = boost::beast; +namespace websocket = beast::websocket; +namespace net = boost::asio; +namespace ssl = boost::asio::ssl; +using tcp = boost::asio::ip::tcp; +using json = nlohmann::json; + + +class VisSession : public std::enable_shared_from_this<VisSession> +{ + //net::io_context m_ioc; + tcp::resolver m_resolver; + tcp::resolver::results_type m_results; + std::string m_hostname; + websocket::stream<beast::ssl_stream<beast::tcp_stream>> m_ws; + beast::flat_buffer m_buffer; + +public: + // Resolver and socket require an io_context + explicit VisSession(const VisConfig &config, net::io_context& ioc, ssl::context& ctx); + + // Start the asynchronous operation + void run(); + +protected: + VisConfig m_config; + std::atomic_uint m_requestid; + + void on_resolve(beast::error_code error, tcp::resolver::results_type results); + + void connect(); + + void on_connect(beast::error_code error, tcp::resolver::results_type::endpoint_type endpoint); + + void on_ssl_handshake(beast::error_code error); + + void on_handshake(beast::error_code error); + + void on_authorize(beast::error_code error, std::size_t bytes_transferred); + + void on_write(beast::error_code error, std::size_t bytes_transferred); + + void on_read(beast::error_code error, std::size_t bytes_transferred); + + void get(const std::string &path); + + void set(const std::string &path, const std::string &value); + + void subscribe(const std::string &path); + + void handle_message(const json &message); + + bool parseData(const json &message, std::string &path, std::string &value, std::string ×tamp); + + virtual void handle_authorized_response(void) = 0; + + virtual void handle_get_response(std::string &path, std::string &value, std::string ×tamp) = 0; + + virtual void handle_notification(std::string &path, std::string &value, std::string ×tamp) = 0; + +}; + +#endif // _VIS_SESSION_HPP |