[Asterisk-code-review] res_aeap & res_speech_aeap: Add Asterisk External Application Protocol (asterisk[master])

Kevin Harwell asteriskteam at digium.com
Mon Nov 8 12:23:43 CST 2021


Kevin Harwell has uploaded this change for review. ( https://gerrit.asterisk.org/c/asterisk/+/17330 )


Change subject: res_aeap & res_speech_aeap: Add Asterisk External Application Protocol
......................................................................

res_aeap & res_speech_aeap: Add Asterisk External Application Protocol

Add framework to connect to, and read and write protocol based
messages from and to an external application using an Asterisk
External Application Protocol (AEAP). This has been divided into
several abstractions:

 1. transport - base communication layer (currently websocket only)
 2. message - AEAP description and data (currently JSON only)
 3. transaction - links/binds requests and responses
 4. aeap - transport, message, and transaction handler/manager

This patch also adds an AEAP implementation for speech to text.
Existing speech API callbacks for speech to text have been completed
making it possible for Asterisk to connect to a configured external
translator service and provide audio for STT. Results can also be
received from the external translator, and made available as speech
results in Asterisk.

Unit tests have also been created that test the AEAP framework, and
also the speech to text implementation.

ASTERISK-29726 #close

Change-Id: Iaa4b259f84aa63501e5fd2a6fb107f900b4d4ed2
---
A include/asterisk/res_aeap.h
A include/asterisk/res_aeap_message.h
M res/Makefile
M res/res_aeap.c
A res/res_aeap.exports.in
A res/res_aeap/aeap.c
A res/res_aeap/general.c
A res/res_aeap/general.h
A res/res_aeap/logger.h
A res/res_aeap/message.c
A res/res_aeap/message_json.c
A res/res_aeap/transaction.c
A res/res_aeap/transaction.h
A res/res_aeap/transport.c
A res/res_aeap/transport.h
A res/res_aeap/transport_websocket.c
A res/res_aeap/transport_websocket.h
A res/res_speech_aeap.c
A tests/test_aeap.c
A tests/test_aeap_transaction.c
A tests/test_aeap_transport.c
21 files changed, 4,397 insertions(+), 43 deletions(-)



  git pull ssh://gerrit.asterisk.org:29418/asterisk refs/changes/30/17330/1

diff --git a/include/asterisk/res_aeap.h b/include/asterisk/res_aeap.h
new file mode 100644
index 0000000..79cf702
--- /dev/null
+++ b/include/asterisk/res_aeap.h
@@ -0,0 +1,339 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*! \file
+ * \brief Asterisk External Application Protocol API
+ */
+
+#ifndef AST_RES_AEAP_H
+#define AST_RES_AEAP_H
+
+#include <stdint.h>
+
+struct ast_sorcery_observer;
+struct ast_variable;
+
+struct ast_aeap_client_config;
+struct ast_aeap_message;
+
+/*!
+ * \brief Add a sorcery client configuration observer for an Asterisk external application
+ *
+ * \param observer The observer to add
+ *
+ * \returns 0 if successfully add, -1 on error
+ */
+int ast_aeap_client_config_observer_add(const struct ast_sorcery_observer *observer);
+
+/*!
+ * \brief Remove a sorcery client configuration observer for an Asterisk external application
+ *
+ * \param observer The observer to remove
+ */
+void ast_aeap_client_config_observer_remove(const struct ast_sorcery_observer *observer);
+
+/*!
+ * \brief Check a given protocol against that in an Asterisk external application configuration
+ *
+ * \param config A configuration object
+ * \param protocol The protocol to check
+ *
+ * \returns True if the configuration's protocol matches, false otherwise
+ */
+int ast_aeap_client_config_has_protocol(const struct ast_aeap_client_config *cfg,
+	const char *protocol);
+
+/*!
+ * \brief Retrieve a list of configuration attributes
+ *
+ * \param id configuration id/sorcery lookup key
+ *
+ * \returns variables, or NULL on error
+ */
+struct ast_variable *ast_aeap_attributes_get(const char *id);
+
+/*!
+ * \brief An Asterisk external application object
+ *
+ * Connects to an external application, sending and receiving data, and
+ * dispatches received data to registered handlers.
+ */
+struct ast_aeap;
+
+/*!
+ * \brief Event raised when a message is received
+ *
+ * \param aeap An Asterisk external application object
+ * \param message The received message
+ * \param obj Associated user object
+ *
+ * \returns 0 on if message handled, otherwise non-zero
+ */
+typedef int (*ast_aeap_on_message)(struct ast_aeap *aeap, struct ast_aeap_message *message, void *obj);
+
+/*!
+ * \brief An Asterisk external application message handler
+ *
+ * Used to register message handlers with an AEAP object.
+ */
+struct ast_aeap_message_handler {
+	/*! The handler name */
+	const char *name;
+	/*! Callback triggered when on a name match */
+	ast_aeap_on_message on_message;
+};
+
+/*!
+ * \brief Event raised when a sent message does not receive a reply within
+ *        a specified time interval
+ *
+ * \param aeap An Asterisk external application object
+ * \param message The message sent that received no response
+ * \param obj Associated user object
+ */
+typedef void (*ast_aeap_on_timeout)(struct ast_aeap *aeap, struct ast_aeap_message *message, void *obj);
+
+/*!
+ * \brief Callback to cleanup a user object
+ *
+ * \param obj The user object
+ */
+typedef void (*ast_aeap_user_obj_cleanup)(void *obj);
+
+/*!
+ * \brief Supported Asterisk external application data types
+ */
+enum AST_AEAP_DATA_TYPE {
+	AST_AEAP_DATA_TYPE_NONE,
+	AST_AEAP_DATA_TYPE_BINARY,
+	AST_AEAP_DATA_TYPE_STRING,
+};
+
+/*!
+ * \brief Callbacks and other parameters used by an Asterisk external application object
+ */
+struct ast_aeap_params {
+	/*!
+	 * If true pass along error messages to the implementation.
+	 * Otherwise log it only, and consider it handled.
+	 */
+	unsigned int emit_error;
+
+	/*! The message type used for communication */
+	const struct ast_aeap_message_type *msg_type;
+
+	/*! Response handlers array */
+	const struct ast_aeap_message_handler *response_handlers;
+	/*! The number of response handlers */
+	uintmax_t response_handlers_size;
+
+	/*! Request handlers array */
+	const struct ast_aeap_message_handler *request_handlers;
+	/*! The number of request handlers */
+	uintmax_t request_handlers_size;
+
+	/*!
+	 * \brief Raised when binary data is received
+	 *
+	 * \param aeap An Asterisk external application object
+	 * \param buf The buffer containing binary data
+	 * \param size The size of the buffer
+	 */
+	void (*on_binary)(struct ast_aeap *aeap, const void *buf, intmax_t size);
+
+	/*!
+	 * \brief Raised when string data is received
+	 *
+	 * \param aeap An Asterisk external application object
+	 * \param buf The buffer containing string data
+	 * \param size The size/length of the string
+	 */
+	void (*on_string)(struct ast_aeap *aeap, const char *buf, intmax_t size);
+
+	/*!
+	 * \brief Raised when an error occurs during reading
+	 *
+	 * \note This is an AEAP transport level read error event
+	 *
+	 * \note When this event is triggered the client has also
+	 *       been disconnected.
+	 *
+	 * \param aeap An Asterisk external application object
+	 */
+	void (*on_error)(struct ast_aeap *aeap);
+};
+
+/*!
+ * \brief Create an Asterisk external application object
+ *
+ * \param type The type of underlying transport
+ * \param params Callbacks and other parameters to use
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create(const char *type, const struct ast_aeap_params *params);
+
+/*!
+ * \brief Create an Asterisk external application object by sorcery id
+ *
+ * \param id The sorcery id to lookup
+ * \param params Callbacks and other parameters to use
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create_by_id(const char *id, const struct ast_aeap_params *params);
+
+/*!
+ * \brief Connect to an external application
+ *
+ * \param aeap An Asterisk external application object
+ * \param url The url to connect to
+ * \param protocol A protocol to use
+ *
+ * \returns 0 if able to connect, -1 on error
+ */
+int ast_aeap_connect(struct ast_aeap *aeap, const char *url, const char *protocol);
+
+/*!
+ * \brief Create and connect to an Asterisk external application by sorcery id
+ *
+ * \param id The sorcery id to lookup
+ * \param params Callbacks and other parameters to use
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create_and_connect_by_id(const char *id,
+	const struct ast_aeap_params *params);
+
+/*!
+ * \brief Create and connect to an Asterisk external application
+ *
+ * \param type The type of client connection to make
+ * \param params Callbacks and other parameters to use
+ * \param url The url to connect to
+ * \param protocol A protocol to use
+ *
+ * \returns A new ao2 reference counted aeap object, or NULL on error
+ */
+struct ast_aeap *ast_aeap_create_and_connect(const char *type,
+	const struct ast_aeap_params *params, const char *url, const char *protocol);
+
+/*!
+ * \brief Disconnect an Asterisk external application object
+ *
+ * \param aeap An Asterisk external application object
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_disconnect(struct ast_aeap *aeap);
+
+/*!
+ * \brief Register a user data object
+ *
+ * \note The clean up is called on un-register, if one is specified
+ *
+ * \param aeap An Asterisk external application object
+ * \param id The look up id for the object
+ * \param obj The user object to register
+ * \param cleanup Optional user object clean up callback
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_user_data_register(struct ast_aeap *aeap, const char *id, void *obj,
+	ast_aeap_user_obj_cleanup cleanup);
+
+/*!
+ * \brief Un-register a user data object
+ *
+ * \param aeap An Asterisk external application object
+ * \param id The look up id for the object
+ */
+void ast_aeap_user_data_unregister(struct ast_aeap *aeap, const char *id);
+
+/*!
+ * \brief Retrieve a registered user data object by its id
+ *
+ * \note A caller should take care not to un-register the retrieved user data object
+ *       while still using it if it was registered with a cleanup handler.
+ *
+ * \param data A user data object
+ *
+ * \returns A user data object
+ */
+void *ast_aeap_user_data_object_by_id(struct ast_aeap *aeap, const char *id);
+
+/*!
+ * \brief Send a binary data to an external application
+ *
+ * \param aeap An Asterisk external application object
+ * \param buf Binary data to send
+ * \param size The size of the binary data
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_send_binary(struct ast_aeap *aeap, const void *buf, uintmax_t size);
+
+/*!
+ * \brief Send a message to an external application
+ *
+ * \note "Steals" the given message reference, thus callers are not required to un-ref
+ *       the message object after calling this function.
+ *
+ * \param aeap An Asterisk external application object
+ * \param msg The message to send
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_send_msg(struct ast_aeap *aeap, struct ast_aeap_message *msg);
+
+/*!
+ * \brief Parameters to be used when sending a transaction based message
+ */
+struct ast_aeap_tsx_params {
+	/*! The message to send */
+	struct ast_aeap_message *msg;
+	/*! The amount of time (in milliseconds) to wait for a received message */
+	int timeout;
+	/*! Raised when no message is received in an allotted time */
+	ast_aeap_on_timeout on_timeout;
+	/*! Whether or not to block the current thread, and wait for a received message */
+	int wait;
+	/*! Optional user object to pass to handlers */
+	void *obj;
+	/*! Optional user object cleanup callback */
+	ast_aeap_user_obj_cleanup obj_cleanup;
+};
+
+/*!
+ * \brief Send a transaction based message to an external application using the given parameters
+ *
+ * \note "Steals" the given message reference, thus callers are not required to un-ref
+ *       the message object after calling this function.
+ *
+ * \note Also handles cleaning up the user object if the obj_cleanup callback
+ *       is specified.
+ *
+ * \param aeap An Asterisk external application object
+ * \param msg The message to send
+ * \param params Additional parameters to consider when sending (optional)
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_send_msg_tsx(struct ast_aeap *aeap, struct ast_aeap_tsx_params *params);
+
+#endif /* AST_RES_AEAP_H */
diff --git a/include/asterisk/res_aeap_message.h b/include/asterisk/res_aeap_message.h
new file mode 100644
index 0000000..f971cd2
--- /dev/null
+++ b/include/asterisk/res_aeap_message.h
@@ -0,0 +1,395 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*! \file
+ * \brief Asterisk External Application Protocol Message API
+ */
+
+#ifndef AST_AEAP_MESSAGE_H
+#define AST_AEAP_MESSAGE_H
+
+#include <stdint.h>
+
+#include "asterisk/res_aeap.h"
+
+struct ast_aeap_message;
+
+/*!
+ * \brief Message type virtual method table
+ */
+struct ast_aeap_message_type {
+	/*! The size of this type */
+	size_t type_size;
+	/*! The name of this type */
+	const char *type_name;
+	/*! The type to serialize to, and de-serialize from */
+	enum AST_AEAP_DATA_TYPE serial_type;
+
+	/*!
+	 * \brief Construct/Initialize a message object
+	 *
+	 * \param self The message object to initialize
+	 * \param params Other optional parameter(s) to possibly use
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*construct1)(struct ast_aeap_message *self, const void *params);
+
+	/*!
+	 * \brief Construct/Initialize a message object
+	 *
+	 * \param self The message object to initialize
+	 * \param msg_type The type of message (e.g. request or response)
+	 * \param name The name of the message
+	 * \param id The message id
+	 * \param version Optional version number
+	 * \param status Optional status (e.g okay or error)
+	 * \param error_msg Optional error message text
+	 * \param params Other optional parameter(s) to possibly use
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*construct2)(struct ast_aeap_message *self, const char *msg_type, const char *name,
+		const char *id, const char *version, const char *status, const char *error_msg,
+		const void *params);
+
+	/*!
+	 * \brief Destruct/Cleanup object resources
+	 *
+	 * \param self The message object being destructed
+	 */
+	void (*destruct)(struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Deserialize the given buffer into a message object
+	 *
+	 * \param self The message object to deserialize into
+	 * \param buf The buffer to deserialize
+	 * \param size The size/length of the buffer
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*deserialize)(struct ast_aeap_message *self, const void *buf, intmax_t size);
+
+	/*!
+	 * \brief Serialize the message object into byte/char buffer
+	 *
+	 * \param self The message object to serialize
+	 * \param buf [out] The buffer to hold the "packed" data
+	 * \param size [out] The size of the data written to the buffer
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*serialize)(const struct ast_aeap_message *self, void **buf, intmax_t *size);
+
+	/*!
+	 * \brief Retrieve a message id
+	 *
+	 * \param self The message object
+	 *
+	 * \returns The message id
+	 */
+	const char *(*id)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Set a message id.
+	 *
+	 * \param self The message object
+	 * \param id The id to set
+	 *
+	 * \returns 0 on success, -1 on error
+	 */
+	int (*id_set)(struct ast_aeap_message *self, const char *id);
+
+	/*!
+	 * \brief Retrieve a message name
+	 *
+	 * \param self The message object
+	 *
+	 * \returns The message name
+	 */
+	const char *(*name)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve the core message data/body
+	 *
+	 * \param self This message object
+	 */
+	void *(*data)(struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve whether or not this is a request message
+	 *
+	 * \param self The message object
+	 *
+	 * \returns True if message is a request, false otherwise
+	 */
+	int (*is_request)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve whether or not this is a response message
+	 *
+	 * \param self The message object
+	 *
+	 * \returns True if message is a response, false otherwise
+	 */
+	int (*is_response)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve whether or not this is an error message
+	 *
+	 * \param self The message object
+	 *
+	 * \returns True if message has an error status, false otherwise
+	 */
+	int (*is_error)(const struct ast_aeap_message *self);
+
+	/*!
+	 * \brief Retrieve the error message if it has one
+	 *
+	 * \param self The message object
+	 *
+	 * \returns The error message if available
+	 */
+	const char *(*error_msg)(const struct ast_aeap_message *self);
+};
+
+/*!
+ * \brief Asterisk external application base message
+ */
+struct ast_aeap_message {
+	/*! The type virtual table */
+	const struct ast_aeap_message_type *type;
+};
+
+/*!
+ * \brief Retrieve the serial type a message type
+ *
+ * \param type A message type
+ *
+ * \returns The type's serial type
+ */
+enum AST_AEAP_DATA_TYPE ast_aeap_message_serial_type(const struct ast_aeap_message_type *type);
+
+/*!
+ * \brief Create an Asterisk external application message object
+ *
+ * \param type The type of message object to create
+ * \param params Any parameter(s) to pass to the type's constructor
+ *
+ * \returns An ao2 reference counted AEAP message object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create1(const struct ast_aeap_message_type *type,
+	const void *params);
+
+/*!
+ * \brief Create an Asterisk external application error message from a given message.
+ *
+ * \param type The type of message object to create
+ * \param msg_type The type of message (e.g. request or response)
+ * \param name The name of the message
+ * \param id The message id
+ * \param version Optional version number
+ * \param status Optional status (e.g okay or error)
+ * \param error_msg Optional error message text
+ * \param params Other optional parameter(s) to possibly use
+ *
+ * \returns An ao2 reference counted AEAP message object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create2(const struct ast_aeap_message_type *type,
+	const char *msg_type, const char *name, const char *id, const char *version,
+	const char *status, const char *error_msg, const void *params);
+
+/*!
+ * \brief Create an Asterisk external application request object, and generate an id for it
+ *
+ * \param type The type of message object to create
+ * \param params Any parameter(s) to pass to the type's constructor
+ * \param name The name of the message
+ * \param id Optional id (if NULL an id is generated)
+ * \param version Optional version number
+ * \param params Other optional parameter(s) to possibly use
+ *
+ * \returns An ao2 reference counted AEAP request object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create_request(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const char *version, const void *params);
+
+/*!
+ * \brief Create an Asterisk external application response object
+ *
+ * \param type The type of message object to create
+ * \param params Any parameter(s) to pass to the type's constructor
+ * \param name The name of the message
+ * \param id Optional id
+ * \param version Optional version number
+ * \param params Other optional parameter(s) to possibly use
+ *
+ * \returns An ao2 reference counted AEAP response object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_create_response(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const char *version, const void *params);
+
+/*!
+ * \brief Deserialize the given buffer into an Asterisk external application message object
+ *
+ * \param type The message type to create, and deserialize to
+ * \param buf The buffer to deserialize
+ * \param size The size/length of the buffer
+ *
+ * \returns An ao2 reference counted AEAP message object, or NULL on error
+ */
+struct ast_aeap_message *ast_aeap_message_deserialize(const struct ast_aeap_message_type *type,
+	const void *buf, intmax_t size);
+
+/*!
+ * \brief Serialize the given message object into a byte/char buffer
+ *
+ * \param message The message object to serialize
+ * \param buf [out] The buffer to hold the "packed" data
+ * \param size [out] The size of the data written to the buffer
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_message_serialize(const struct ast_aeap_message *message,
+	void **buf, intmax_t *size);
+
+/*!
+ * \brief Retrieve a message id
+ *
+ * \param message A message object
+ *
+ * \returns The message id, or an empty string
+ */
+const char *ast_aeap_message_id(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Set a message id.
+ *
+ * \param message A message object
+ * \param id The id to set
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_message_id_set(struct ast_aeap_message *message, const char *id);
+
+/*!
+ * \brief Generate an id, and set it for the message
+ *
+ * \param message A message object
+ *
+ * \returns 0 on success, -1 on error
+ */
+int ast_aeap_message_id_generate(struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve a message name
+ *
+ * \param message A message object
+ *
+ * \returns The message name, or an empty string
+ */
+const char *ast_aeap_message_name(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Check whether or not a message's name matches the given one
+ *
+ * \note Case insensitive
+ *
+ * \param message A message object
+ * \param message name The name to check against
+ *
+ * \returns True if matched, false otherwise
+ */
+int ast_aeap_message_is_named(const struct ast_aeap_message *message, const char *name);
+
+/*!
+ * \brief Retrieve the core message data/body
+ *
+ * \param message A message object
+ */
+void *ast_aeap_message_data(struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve whether or not this is a request message
+ *
+ * \param message A message object
+ *
+ * \returns True if the message is a request, false otherwise
+ */
+int ast_aeap_message_is_request(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve whether or not this is a response message
+ *
+ * \param message A message object
+ *
+ * \returns True if the message is a response, false otherwise
+ */
+int ast_aeap_message_is_response(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve whether or not this is an error message
+ *
+ * \param message A message object
+ *
+ * \returns True if message has an error status, false otherwise
+ */
+int ast_aeap_message_is_error(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Retrieve the error message if it has one
+ *
+ * \param message A message object
+ *
+ * \returns The error message if available, or an empty string
+ */
+const char *ast_aeap_message_error_msg(const struct ast_aeap_message *message);
+
+/*!
+ * \brief Asterisk external application JSON message type
+ */
+extern const struct ast_aeap_message_type *ast_aeap_message_type_json;
+
+/*!
+ * \brief Create a JSON request message with the given 'json'
+ *
+ * \param name The name of the request
+ * \param id Optional id to give to the message (if NULL and id is generated)
+ * \param version Optional version number
+ * \param json JSON message data
+ *
+ * \returns A JSON based request object
+ */
+struct ast_aeap_message *ast_aeap_message_json_create_request(const char *name,
+	const char *id, const char *version, const struct ast_json *json);
+
+/*!
+ * \brief Create a JSON response message with the given 'json'
+ *
+ * \param name The name of the response
+ * \param id Optional id
+ * \param version Optional version number
+ * \param json JSON message data
+ *
+ * \returns A JSON based response object
+ */
+struct ast_aeap_message *ast_aeap_message_json_create_response(const char *name,
+	const char *id, const char *version, const struct ast_json *json);
+
+#endif /* AST_AEAP_MESSAGE_H */
diff --git a/res/Makefile b/res/Makefile
index 45ef749..d541300 100644
--- a/res/Makefile
+++ b/res/Makefile
@@ -69,6 +69,7 @@
 $(call MOD_ADD_C,res_ari_model,ari/ari_model_validators.c)
 $(call MOD_ADD_C,res_stasis_recording,stasis_recording/stored.c)
 $(call MOD_ADD_C,res_stir_shaken,$(wildcard res_stir_shaken/*.c))
+$(call MOD_ADD_C,res_aeap,$(wildcard res_aeap/*.c))
 
 res_parking.o: _ASTCFLAGS+=$(AST_NO_FORMAT_TRUNCATION)
 snmp/agent.o: _ASTCFLAGS+=-fPIC
diff --git a/res/res_aeap.c b/res/res_aeap.c
index b6c5841..c91ccb6 100644
--- a/res/res_aeap.c
+++ b/res/res_aeap.c
@@ -17,29 +17,37 @@
  */
 
 /*** MODULEINFO
+	<depend>res_http_websocket</depend>
 	<support_level>core</support_level>
  ***/
 
 #include "asterisk.h"
 
+#include "asterisk/astobj2.h"
 #include "asterisk/module.h"
 #include "asterisk/sorcery.h"
 #include "asterisk/cli.h"
 #include "asterisk/format.h"
 #include "asterisk/format_cap.h"
+#include "asterisk/res_aeap.h"
+
+#include "res_aeap/general.h"
 
 /*** DOCUMENTATION
 	<configInfo name="res_aeap" language="en_US">
 		<synopsis>Asterisk External Application Protocol (AEAP) module for Asterisk</synopsis>
 		<configFile name="aeap.conf">
-			<configObject name="server">
-				<synopsis>AEAP server options</synopsis>
+			<configObject name="client">
+				<synopsis>AEAP client options</synopsis>
 				<configOption name="type">
-					<synopsis>Must be of type 'server'.</synopsis>
+					<synopsis>Must be of type 'client'.</synopsis>
 				</configOption>
-				<configOption name="server_url">
+				<configOption name="url">
 					<synopsis>The URL of the server to connect to.</synopsis>
 				</configOption>
+				<configOption name="protocol">
+					<synopsis>The application protocol.</synopsis>
+				</configOption>
 				<configOption name="codecs">
 				        <synopsis>Optional media codec(s)</synopsis>
 					<description><para>
@@ -56,30 +64,32 @@
 /* Asterisk External Application Protocol sorcery object */
 static struct ast_sorcery *aeap_sorcery;
 
-struct aeap_server
+struct ast_aeap_client_config
 {
 	SORCERY_OBJECT(details);
 	AST_DECLARE_STRING_FIELDS(
 		/*! The URL of the server to connect to */
-		AST_STRING_FIELD(server_url);
+		AST_STRING_FIELD(url);
+		/*! The application protocol */
+		AST_STRING_FIELD(protocol);
 	);
 	/*! An optional list of codecs that will be used if provided */
 	struct ast_format_cap *codecs;
 };
 
-static void aeap_server_destructor(void *obj)
+static void client_config_destructor(void *obj)
 {
-	struct aeap_server *cfg = obj;
+	struct ast_aeap_client_config *cfg = obj;
 
 	ast_string_field_free_memory(cfg);
 	ao2_cleanup(cfg->codecs);
 }
 
-static void *aeap_server_alloc(const char *name)
+static void *client_config_alloc(const char *name)
 {
-	struct aeap_server *cfg;
+	struct ast_aeap_client_config *cfg;
 
-	cfg = ast_sorcery_generic_alloc(sizeof(*cfg), aeap_server_destructor);
+	cfg = ast_sorcery_generic_alloc(sizeof(*cfg), client_config_destructor);
 	if (!cfg) {
 		return NULL;
 	}
@@ -97,31 +107,37 @@
 	return cfg;
 }
 
-static int aeap_server_apply(const struct ast_sorcery *sorcery, void *obj)
+static int client_config_apply(const struct ast_sorcery *sorcery, void *obj)
 {
-	struct aeap_server *cfg = obj;
+	struct ast_aeap_client_config *cfg = obj;
 
-	if (ast_strlen_zero(cfg->server_url)) {
-		ast_log(LOG_ERROR, "AEAP - Server URL must be present for server '%s'\n", ast_sorcery_object_get_id(cfg));
+	if (ast_strlen_zero(cfg->url)) {
+		ast_log(LOG_ERROR, "AEAP - URL must be present for '%s'\n", ast_sorcery_object_get_id(cfg));
 		return -1;
 	}
 
-	if (!ast_begins_with(cfg->server_url, "ws")) {
-		ast_log(LOG_ERROR, "AEAP - Server URL must be ws or wss for server '%s'\n", ast_sorcery_object_get_id(cfg));
+	if (!ast_begins_with(cfg->url, "ws")) {
+		ast_log(LOG_ERROR, "AEAP - URL must be ws or wss for '%s'\n", ast_sorcery_object_get_id(cfg));
 		return -1;
 	}
 
 	return 0;
 }
 
-static struct aeap_server *aeap_server_get(const char *id)
+int ast_aeap_client_config_has_protocol(const struct ast_aeap_client_config *cfg,
+	const char *protocol)
 {
-	return ast_sorcery_retrieve_by_id(aeap_sorcery, "server", id);
+	return !strcmp(protocol, cfg->protocol);
 }
 
-static struct ao2_container *aeap_server_get_all(void)
+static struct ast_aeap_client_config *client_config_get(const char *id)
 {
-	return ast_sorcery_retrieve_by_fields(aeap_sorcery, "server",
+	return ast_sorcery_retrieve_by_id(aeap_sorcery, "client", id);
+}
+
+static struct ao2_container *client_config_get_all(void)
+{
+	return ast_sorcery_retrieve_by_fields(aeap_sorcery, "client",
 		AST_RETRIEVE_FLAG_MULTIPLE | AST_RETRIEVE_FLAG_ALL, NULL);
 }
 
@@ -179,20 +195,20 @@
 	return 0;
 }
 
-static char *aeap_server_show(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
+static char *client_config_show(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
 {
-	struct aeap_server *cfg;
+	struct ast_aeap_client_config *cfg;
 
 	switch(cmd) {
 	case CLI_INIT:
-		e->command = "aeap show server";
+		e->command = "aeap show client";
 		e->usage =
-			"Usage: aeap show server <id>\n"
-			"       Show the AEAP settings for a given server\n";
+			"Usage: aeap show client <id>\n"
+			"       Show the AEAP settings for a given client\n";
 		return NULL;
 	case CLI_GENERATE:
 		if (a->pos == 3) {
-			return aeap_tab_complete_name(a->word, aeap_server_get_all());
+			return aeap_tab_complete_name(a->word, client_config_get_all());
 		} else {
 			return NULL;
 		}
@@ -202,23 +218,23 @@
 		return CLI_SHOWUSAGE;
 	}
 
-	cfg = aeap_server_get(a->argv[3]);
+	cfg = client_config_get(a->argv[3]);
 	aeap_cli_show(cfg, a, 0);
 	ao2_cleanup(cfg);
 
 	return CLI_SUCCESS;
 }
 
-static char *aeap_server_show_all(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
+static char *client_config_show_all(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a)
 {
 	struct ao2_container *container;
 
 	switch(cmd) {
 	case CLI_INIT:
-		e->command = "aeap show servers";
+		e->command = "aeap show clients";
 		e->usage =
-			"Usage: aeap show servers\n"
-			"       Show all configured AEAP servers\n";
+			"Usage: aeap show clients\n"
+			"       Show all configured AEAP clients\n";
 		return NULL;
 	case CLI_GENERATE:
 		return NULL;
@@ -228,9 +244,9 @@
 		return CLI_SHOWUSAGE;
 	}
 
-	container = aeap_server_get_all();
+	container = client_config_get_all();
 	if (!container || ao2_container_count(container) == 0) {
-		ast_cli(a->fd, "No AEAP servers found\n");
+		ast_cli(a->fd, "No AEAP clients found\n");
 		ao2_cleanup(container);
 		return CLI_SUCCESS;
 	}
@@ -242,10 +258,79 @@
 }
 
 static struct ast_cli_entry aeap_cli[] = {
-	AST_CLI_DEFINE(aeap_server_show, "Show AEAP server configuration by id"),
-	AST_CLI_DEFINE(aeap_server_show_all, "Show all AEAP server configurations"),
+	AST_CLI_DEFINE(client_config_show, "Show AEAP client configuration by id"),
+	AST_CLI_DEFINE(client_config_show_all, "Show all AEAP client configurations"),
 };
 
+static struct ast_aeap *aeap_create(const char *id,	const struct ast_aeap_params *params, int connect)
+{
+	struct ast_aeap_client_config *cfg;
+	struct ast_aeap *aeap;
+	const char *url = NULL;
+	const char *protocol = NULL;
+
+	cfg = client_config_get(id);
+	if (cfg) {
+		url = cfg->url;
+		protocol = cfg->protocol;
+	}
+
+#ifdef TEST_FRAMEWORK
+	else if (ast_begins_with(id, "_aeap_test_")) {
+		url = "ws://127.0.0.1:8088/ws";
+		protocol = id;
+	}
+#endif
+
+	if (!url && !protocol) {
+		ast_log(LOG_ERROR, "AEAP: unable to get configuration for '%s'\n", id);
+		return NULL;
+	}
+
+	aeap = connect ? ast_aeap_create_and_connect(url, params, url, protocol) :
+		ast_aeap_create(url, params);
+
+	ao2_cleanup(cfg);
+	return aeap;
+}
+
+struct ast_aeap *ast_aeap_create_by_id(const char *id, const struct ast_aeap_params *params)
+{
+	return aeap_create(id, params, 0);
+}
+
+struct ast_aeap *ast_aeap_create_and_connect_by_id(const char *id, const struct ast_aeap_params *params)
+{
+	return aeap_create(id, params, 1);
+}
+
+struct ast_variable *ast_aeap_attributes_get(const char *id)
+{
+	struct ast_aeap_client_config *cfg;
+	struct ast_variable *vars;
+
+	cfg = client_config_get(id);
+	if (!cfg) {
+		ast_log(LOG_WARNING, "AEAP: no client configuration '%s' to get attributes\n", id);
+		return NULL;
+	}
+
+	vars = ast_sorcery_objectset_create2(aeap_sorcery, cfg, AST_HANDLER_ONLY_STRING);
+
+	ao2_ref(cfg, -1);
+	return vars;
+}
+
+int ast_aeap_client_config_observer_add(const struct ast_sorcery_observer *observer)
+{
+	return ast_sorcery_observer_add(aeap_sorcery, "client", observer);
+}
+
+void ast_aeap_client_config_observer_remove(const struct ast_sorcery_observer *observer)
+{
+	ast_sorcery_observer_remove(aeap_sorcery, "client", observer);
+}
+
 static int reload_module(void)
 {
 	return 0;
@@ -258,28 +343,35 @@
 
 	ast_cli_unregister_multiple(aeap_cli, ARRAY_LEN(aeap_cli));
 
+	aeap_general_finalize();
+
 	return 0;
 }
 
 static int load_module(void)
 {
+	if (aeap_general_initialize()) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
 	if (!(aeap_sorcery = ast_sorcery_open()))
 	{
 		ast_log(LOG_ERROR, "AEAP - failed to open sorcery\n");
 		return AST_MODULE_LOAD_DECLINE;
 	}
 
-	ast_sorcery_apply_default(aeap_sorcery, "server", "config", "aeap.conf,criteria=type=server");
+	ast_sorcery_apply_default(aeap_sorcery, "client", "config", "aeap.conf,criteria=type=client");
 
-	if (ast_sorcery_object_register(aeap_sorcery, "server", aeap_server_alloc,
-		NULL, aeap_server_apply)) {
-		ast_log(LOG_ERROR, "AEAP - failed to register server sorcery object\n");
+	if (ast_sorcery_object_register(aeap_sorcery, "client", client_config_alloc,
+		NULL, client_config_apply)) {
+		ast_log(LOG_ERROR, "AEAP - failed to register client sorcery object\n");
 		return AST_MODULE_LOAD_DECLINE;
 	}
 
-	ast_sorcery_object_field_register(aeap_sorcery, "server", "type", "", OPT_NOOP_T, 0, 0);
-	ast_sorcery_object_field_register(aeap_sorcery, "server", "server_url", "", OPT_STRINGFIELD_T, 0, STRFLDSET(struct aeap_server, server_url));
-	ast_sorcery_object_field_register(aeap_sorcery, "server", "codecs", "", OPT_CODEC_T, 1, FLDSET(struct aeap_server, codecs));
+	ast_sorcery_object_field_register(aeap_sorcery, "client", "type", "", OPT_NOOP_T, 0, 0);
+	ast_sorcery_object_field_register(aeap_sorcery, "client", "url", "", OPT_STRINGFIELD_T, 0, STRFLDSET(struct ast_aeap_client_config, url));
+	ast_sorcery_object_field_register(aeap_sorcery, "client", "protocol", "", OPT_STRINGFIELD_T, 0, STRFLDSET(struct ast_aeap_client_config, protocol));
+	ast_sorcery_object_field_register(aeap_sorcery, "client", "codecs", "", OPT_CODEC_T, 1, FLDSET(struct ast_aeap_client_config, codecs));
 
 	ast_sorcery_load(aeap_sorcery);
 
@@ -295,4 +387,5 @@
 	.unload = unload_module,
 	.reload = reload_module,
 	.load_pri = AST_MODPRI_CHANNEL_DEPEND,
+	.requires = "res_http_websocket",
 );
diff --git a/res/res_aeap.exports.in b/res/res_aeap.exports.in
new file mode 100644
index 0000000..c1fc5c4
--- /dev/null
+++ b/res/res_aeap.exports.in
@@ -0,0 +1,7 @@
+{
+	global:
+		LINKER_SYMBOL_PREFIXaeap_*;
+		LINKER_SYMBOL_PREFIXast_aeap_*;
+	local:
+		*;
+};
diff --git a/res/res_aeap/aeap.c b/res/res_aeap/aeap.c
new file mode 100644
index 0000000..5acd21d
--- /dev/null
+++ b/res/res_aeap/aeap.c
@@ -0,0 +1,472 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include <pthread.h>
+
+#include "asterisk/astobj2.h"
+#include "asterisk/strings.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#include "logger.h"
+#include "transaction.h"
+#include "transport.h"
+
+#define AEAP_RECV_SIZE 32768
+
+struct aeap_user_data {
+	/*! The user data object */
+	void *obj;
+	/*! A user data identifier */
+	char id[0];
+};
+
+AO2_STRING_FIELD_HASH_FN(aeap_user_data, id);
+AO2_STRING_FIELD_CMP_FN(aeap_user_data, id);
+
+#define USER_DATA_BUCKETS 11
+
+struct ast_aeap {
+	/*! This object's configuration parameters */
+	const struct ast_aeap_params *params;
+	/*! Container for registered user data objects */
+	struct ao2_container *user_data;
+	/*! Transactions container */
+	struct ao2_container *transactions;
+	/*! Transport layer communicator */
+	struct aeap_transport *transport;
+	/*! Object lock */
+	ast_mutex_t lock;
+	/*! Id of thread that reads data from the transport */
+	pthread_t read_thread_id;
+};
+
+static void aeap_destructor(void *obj)
+{
+	struct ast_aeap *aeap = obj;
+
+	ast_aeap_disconnect(aeap);
+
+	aeap_transport_destroy(aeap->transport);
+
+	ao2_cleanup(aeap->transactions);
+
+	ao2_cleanup(aeap->user_data);
+
+	ast_mutex_destroy(&aeap->lock);
+}
+
+struct ast_aeap *ast_aeap_create(const char *transport_type,
+	const struct ast_aeap_params *params)
+{
+	struct ast_aeap *aeap;
+
+	aeap = ao2_alloc(sizeof(*aeap), aeap_destructor);
+	if (!aeap) {
+		ast_log(LOG_ERROR, "AEAP: unable to create");
+		return NULL;
+	}
+
+	ast_mutex_init(&aeap->lock);
+
+	aeap->params = params;
+	aeap->read_thread_id = AST_PTHREADT_NULL;
+
+	aeap->user_data = ao2_container_alloc_hash(AO2_ALLOC_OPT_LOCK_MUTEX, 0, USER_DATA_BUCKETS,
+		aeap_user_data_hash_fn, NULL, aeap_user_data_cmp_fn);
+	if (!aeap->user_data) {
+		aeap_error(aeap, NULL, "unable to create user data container");
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	aeap->transactions = aeap_transactions_create();
+	if (!aeap->transactions) {
+		aeap_error(aeap, NULL, "unable to create transactions container");
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	aeap->transport = aeap_transport_create(transport_type);
+	if (!aeap->transport) {
+		aeap_error(aeap, NULL, "unable to create transport");
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	return aeap;
+}
+
+static struct aeap_user_data *aeap_user_data_create(const char *id, void *obj,
+	ast_aeap_user_obj_cleanup cleanup)
+{
+	struct aeap_user_data *data;
+
+	ast_assert(id != NULL);
+
+	data = ao2_alloc(sizeof(*data) + strlen(id), cleanup);
+	if (!data) {
+		if (cleanup) {
+			cleanup(obj);
+		}
+
+		return NULL;
+	}
+
+	strcpy(data->id, id); /* safe */
+	data->obj = obj;
+
+	return data;
+}
+
+int ast_aeap_user_data_register(struct ast_aeap *aeap, const char *id, void *obj,
+	ast_aeap_user_obj_cleanup cleanup)
+{
+	struct aeap_user_data *data;
+
+	data = aeap_user_data_create(id, obj, cleanup);
+	if (!data) {
+		return -1;
+	}
+
+	if (!ao2_link(aeap->user_data, data)) {
+		ao2_ref(data, -1);
+		return -1;
+	}
+
+	ao2_ref(data, -1);
+	return 0;
+}
+
+void ast_aeap_user_data_unregister(struct ast_aeap *aeap, const char *id)
+{
+	ao2_find(aeap->user_data, id, OBJ_SEARCH_KEY | OBJ_UNLINK | OBJ_NODATA);
+}
+
+void *ast_aeap_user_data_object_by_id(struct ast_aeap *aeap, const char *id)
+{
+	struct aeap_user_data *data;
+	void *obj;
+
+	data = ao2_find(aeap->user_data, id, OBJ_SEARCH_KEY);
+	if (!data) {
+		return NULL;
+	}
+
+	obj = data->obj;
+	ao2_ref(data, -1);
+	return obj;
+}
+
+static int raise_msg_handler(struct ast_aeap *aeap,	const struct ast_aeap_message_handler *handlers,
+	size_t size, struct ast_aeap_message *msg, void *data)
+{
+	ast_aeap_on_message on_message = NULL;
+	size_t i;
+
+	if (!aeap->params->emit_error && ast_aeap_message_is_error(msg)) {
+		aeap_error(aeap, NULL, "%s", ast_aeap_message_error_msg(msg));
+		return -1;
+	}
+
+	for (i = 0; i < size; ++i) {
+		if (ast_strlen_zero(handlers[i].name)) {
+			/* A default handler is specified. Use it if no other match is found */
+			on_message = handlers[i].on_message;
+			continue;
+		}
+
+		if (ast_aeap_message_is_named(msg, handlers[i].name)) {
+			on_message = handlers[i].on_message;
+			break;
+		}
+	}
+
+	if (on_message) {
+		return on_message(aeap, msg, data);
+	}
+
+	/* Respond with un-handled error */
+	ast_aeap_send_msg(aeap, ast_aeap_message_create2(aeap->params->msg_type, "response",
+		ast_aeap_message_name(msg), ast_aeap_message_id(msg), NULL, "error",
+		"Unsupported and un-handled message", NULL));
+
+	return 0;
+}
+
+static void raise_msg(struct ast_aeap *aeap, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE serial_type)
+{
+	struct ast_aeap_message *msg;
+	struct aeap_transaction *tsx;
+	int res = 0;
+
+	if (!aeap->params || !aeap->params->msg_type ||
+		ast_aeap_message_serial_type(aeap->params->msg_type) != serial_type ||
+		!(msg = ast_aeap_message_deserialize(aeap->params->msg_type, buf, size))) {
+		return;
+	}
+
+	/* See if this msg is involved in a transaction */
+	tsx = aeap_transaction_get(aeap->transactions, ast_aeap_message_id(msg));
+
+	/* If so go ahead and cancel the timeout timer */
+	aeap_transaction_cancel_timer(tsx);
+
+	if (aeap->params->request_handlers && ast_aeap_message_is_request(msg)) {
+		res = raise_msg_handler(aeap, aeap->params->request_handlers, aeap->params->request_handlers_size,
+			msg, tsx ? aeap_transaction_user_obj(tsx) : NULL);
+	} else if (aeap->params->response_handlers && ast_aeap_message_is_response(msg)) {
+		res = raise_msg_handler(aeap, aeap->params->response_handlers, aeap->params->response_handlers_size,
+			msg, tsx ? aeap_transaction_user_obj(tsx) : NULL);
+	}
+
+	/* Complete transaction (Note, removes tsx ref) */
+	aeap_transaction_end(tsx, res);
+
+	ao2_ref(msg, -1);
+}
+
+static void *aeap_receive(void *data)
+{
+	struct ast_aeap *aeap = data;
+	void *buf;
+
+	buf = ast_calloc(1, AEAP_RECV_SIZE);
+	if (!buf) {
+		aeap_error(aeap, NULL, "unable to create read buffer");
+		goto aeap_receive_error;
+	}
+
+	while (aeap_transport_is_connected(aeap->transport)) {
+		enum AST_AEAP_DATA_TYPE rtype;
+		intmax_t size;
+
+		size = aeap_transport_read(aeap->transport, buf, AEAP_RECV_SIZE, &rtype);
+		if (size < 0) {
+			goto aeap_receive_error;
+		}
+
+		if (!size) {
+			continue;
+		}
+
+		switch (rtype) {
+		case AST_AEAP_DATA_TYPE_BINARY:
+			if (aeap->params && aeap->params->on_binary) {
+				aeap->params->on_binary(aeap, buf, size);
+			}
+			break;
+		case AST_AEAP_DATA_TYPE_STRING:
+			if (aeap->params && aeap->params->on_string) {
+				aeap->params->on_string(aeap, (const char *)buf, size - 1);
+			}
+			break;
+		default:
+			break;
+		}
+
+		raise_msg(aeap, buf, size, rtype);
+	};
+
+	ast_free(buf);
+	return NULL;
+
+aeap_receive_error:
+	/*
+	 * An unrecoverable error occurred so ensure the aeap and transport reset
+	 * to a disconnected state. We don't want this thread to "join" itself so set
+	 * its id to NULL prior to disconnecting.
+	 */
+	aeap_error(aeap, NULL, "unrecoverable read error, disconnecting");
+
+	ast_mutex_lock(&aeap->lock);
+	aeap->read_thread_id = AST_PTHREADT_NULL;
+	ast_mutex_unlock(&aeap->lock);
+
+	ast_aeap_disconnect(aeap);
+
+	ast_free(buf);
+
+	if (aeap->params && aeap->params->on_error) {
+		aeap->params->on_error(aeap);
+	}
+
+	return NULL;
+}
+
+int ast_aeap_connect(struct ast_aeap *aeap, const char *url, const char *protocol)
+{
+	SCOPED_MUTEX(lock, &aeap->lock);
+
+	if (aeap_transport_is_connected(aeap->transport)) {
+		/* Should already be connected, so nothing to do */
+		return 0;
+	}
+
+	if (aeap_transport_connect(aeap->transport, url, protocol)) {
+		aeap_error(aeap, NULL, "unable to connect transport");
+		return -1;
+	}
+
+	if (ast_pthread_create_background(&aeap->read_thread_id, NULL,
+			aeap_receive, aeap)) {
+		aeap_error(aeap, NULL, "unable to start read thread: %s",
+			strerror(errno));
+		ast_aeap_disconnect(aeap);
+		return -1;
+	}
+
+	return 0;
+}
+
+struct ast_aeap *ast_aeap_create_and_connect(const char *type,
+	const struct ast_aeap_params *params, const char *url, const char *protocol)
+{
+	struct ast_aeap *aeap;
+
+	aeap = ast_aeap_create(type, params);
+	if (!aeap) {
+		return NULL;
+	}
+
+	if (ast_aeap_connect(aeap, url, protocol)) {
+		ao2_ref(aeap, -1);
+		return NULL;
+	}
+
+	return aeap;
+}
+
+int ast_aeap_disconnect(struct ast_aeap *aeap)
+{
+	SCOPED_MUTEX(lock, &aeap->lock);
+
+	aeap_transport_disconnect(aeap->transport);
+
+	if (aeap->read_thread_id != AST_PTHREADT_NULL) {
+		/*
+		 * The read thread calls disconnect if an error occurs, so
+		 * unlock the aeap before "joining" to avoid a deadlock.
+		 */
+		ast_mutex_unlock(&aeap->lock);
+		pthread_join(aeap->read_thread_id, NULL);
+		ast_mutex_lock(&aeap->lock);
+
+		aeap->read_thread_id = AST_PTHREADT_NULL;
+	}
+
+	return 0;
+}
+
+static int aeap_send(struct ast_aeap *aeap, const void *buf, uintmax_t size,
+	enum AST_AEAP_DATA_TYPE type)
+{
+	intmax_t num;
+
+	num = aeap_transport_write(aeap->transport, buf, size, type);
+
+	if (num < 0) {
+		aeap_error(aeap, NULL, "error sending data");
+		return -1;
+	}
+
+	if (num < size) {
+		aeap_error(aeap, NULL, "not all data sent");
+		return -1;
+	}
+
+	if (num > size) {
+		aeap_error(aeap, NULL, "sent data truncated");
+		return -1;
+	}
+
+	return 0;
+}
+
+int ast_aeap_send_binary(struct ast_aeap *aeap, const void *buf, uintmax_t size)
+{
+	return aeap_send(aeap, buf, size, AST_AEAP_DATA_TYPE_BINARY);
+}
+
+int ast_aeap_send_msg(struct ast_aeap *aeap, struct ast_aeap_message *msg)
+{
+	void *buf;
+	intmax_t size;
+	int res;
+
+	if (!msg) {
+		aeap_error(aeap, NULL, "no message to send");
+		return -1;
+	}
+
+	if (ast_aeap_message_serialize(msg, &buf, &size)) {
+		aeap_error(aeap, NULL, "unable to serialize outgoing message");
+		ao2_ref(msg, -1);
+		return -1;
+	}
+
+	res = aeap_send(aeap, buf, size, msg->type->serial_type);
+
+	ast_free(buf);
+	ao2_ref(msg, -1);
+
+	return res;
+}
+
+int ast_aeap_send_msg_tsx(struct ast_aeap *aeap, struct ast_aeap_tsx_params *params)
+{
+	struct aeap_transaction *tsx = NULL;
+	int res = 0;
+
+	if (!params) {
+		return -1;
+	}
+
+	if (!params->msg) {
+		aeap_transaction_params_cleanup(params);
+		aeap_error(aeap, NULL, "no message to send");
+		return -1;
+	}
+
+	/* The transaction will take over params cleanup, which includes the msg reference */
+	tsx = aeap_transaction_create_and_add(aeap->transactions,
+		ast_aeap_message_id(params->msg), params, aeap);
+	if (!tsx) {
+		return -1;
+	}
+
+	if (ast_aeap_send_msg(aeap, ao2_bump(params->msg))) {
+		aeap_transaction_end(tsx, -1); /* Removes container, and tsx ref */
+		return -1;
+	}
+
+	if (aeap_transaction_start(tsx)) {
+		aeap_transaction_end(tsx, -1); /* Removes container, and tsx ref */
+		return -1;
+	}
+
+	res = aeap_transaction_result(tsx);
+
+	ao2_ref(tsx, -1);
+
+	return res;
+}
diff --git a/res/res_aeap/general.c b/res/res_aeap/general.c
new file mode 100644
index 0000000..7bd1807
--- /dev/null
+++ b/res/res_aeap/general.c
@@ -0,0 +1,58 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/sched.h"
+
+#include "general.h"
+
+/*! \brief Scheduler for transaction timeouts */
+static struct ast_sched_context *sched = NULL;
+
+struct ast_sched_context *aeap_sched_context(void)
+{
+	return sched;
+}
+
+void aeap_general_finalize(void)
+{
+	if (sched) {
+		ast_sched_context_destroy(sched);
+		sched = NULL;
+	}
+}
+
+int aeap_general_initialize(void)
+{
+	sched = ast_sched_context_create();
+	if (!sched) {
+		ast_log(LOG_ERROR, "AEAP scheduler: unable to create context");
+		return -1;
+	}
+
+	if (ast_sched_start_thread(sched)) {
+		ast_log(LOG_ERROR, "AEAP scheduler: unable to start thread");
+		aeap_general_finalize();
+		return -1;
+	}
+
+	return 0;
+}
+
diff --git a/res/res_aeap/general.h b/res/res_aeap/general.h
new file mode 100644
index 0000000..52a092b
--- /dev/null
+++ b/res/res_aeap/general.h
@@ -0,0 +1,41 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_GENERAL_H
+#define RES_AEAP_GENERAL_H
+
+/*!
+ * \brief Retrieve the scheduling context
+ *
+ * \returns The scheduling context
+ */
+struct ast_sched_context *aeap_sched_context(void);
+
+/*!
+ * \brief Initialize general/common AEAP facilities
+ *
+ * \returns 0 on success, -1 on error
+ */
+int aeap_general_initialize(void);
+
+/*!
+ * \brief Finalize/cleanup general AEAP facilities
+ */
+void aeap_general_finalize(void);
+
+#endif /* RES_AEAP_GENERAL_H */
diff --git a/res/res_aeap/logger.h b/res/res_aeap/logger.h
new file mode 100644
index 0000000..db264c3
--- /dev/null
+++ b/res/res_aeap/logger.h
@@ -0,0 +1,60 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_LOGGER_H
+#define RES_AEAP_LOGGER_H
+
+#include "asterisk.h"
+
+#include "asterisk/logger.h"
+#include "asterisk/strings.h"
+
+/*!
+ * \brief Log an Asterisk external application message
+ *
+ * \param level The logging level
+ * \param obj The object being logged
+ * \param name Optional subsystem name
+ * \param fmt Format string
+ * \param ... Parameters for the format string
+ */
+#define aeap_log(level, obj, name, fmt, ...) \
+	ast_log(level, "AEAP%s%s (%p): " fmt "\n", ast_strlen_zero(name) ? "" : " ", \
+			ast_strlen_zero(name) ? "" : name, obj, ##__VA_ARGS__)
+
+/*!
+ * \brief Log an Asterisk external application error
+ *
+ * \param obj The object being logged
+ * \param name Optional subsystem name
+ * \param fmt Format string
+ * \param ... Parameters for the format string
+ */
+#define aeap_error(obj, name, fmt, ...) aeap_log(LOG_ERROR, obj, name, fmt, ##__VA_ARGS__)
+
+/*!
+ * \brief Log an Asterisk external application warning
+ *
+ * \param obj The object being logged
+ * \param name Optional subsystem name
+ * \param fmt Format string
+ * \param ... Parameters for the format string
+ */
+#define aeap_warn(obj, name, fmt, ...) aeap_log(LOG_WARNING, obj, name, fmt, ##__VA_ARGS__)
+
+#endif /* RES_AEAP_LOGGER_H */
diff --git a/res/res_aeap/message.c b/res/res_aeap/message.c
new file mode 100644
index 0000000..ff549ca
--- /dev/null
+++ b/res/res_aeap/message.c
@@ -0,0 +1,259 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/utils.h"
+#include "asterisk/uuid.h"
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+enum AST_AEAP_DATA_TYPE ast_aeap_message_serial_type(const struct ast_aeap_message_type *type)
+{
+	ast_assert(type != NULL);
+
+	return type->serial_type;
+}
+
+static void message_destructor(void *obj)
+{
+	struct ast_aeap_message *msg = obj;
+
+	if (msg->type->destruct) {
+		msg->type->destruct(msg);
+	}
+}
+
+static struct ast_aeap_message *message_create(const struct ast_aeap_message_type *type)
+{
+	struct ast_aeap_message *msg;
+
+	msg = ao2_alloc(type->type_size, message_destructor);
+	if (!msg) {
+		ast_log(LOG_ERROR, "AEAP message %s: unable to create\n", type->type_name);
+		return NULL;
+	}
+
+	msg->type = type;
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create1(const struct ast_aeap_message_type *type,
+	const void *params)
+{
+	struct ast_aeap_message *msg;
+
+	ast_assert(type != NULL);
+	ast_assert(type->construct1 != NULL);
+
+	msg = message_create(type);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (type->construct1(msg, params)) {
+		ast_log(LOG_ERROR, "AEAP message %s: unable to construct1\n", type->type_name);
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create2(const struct ast_aeap_message_type *type,
+	const char *msg_type, const char *name, const char *id, const char *version,
+	const char *status, const char *error_msg, const void *params)
+{
+	struct ast_aeap_message *msg;
+
+	ast_assert(type != NULL);
+	ast_assert(type->construct2 != NULL);
+	ast_assert(msg_type != NULL);
+	ast_assert(name != NULL);
+	ast_assert(ast_strlen_zero(error_msg) || (status && !strcmp(status, "error")));
+
+	msg = message_create(type);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (type->construct2(msg, msg_type, name, id, version, status, error_msg, params)) {
+		ast_log(LOG_ERROR, "AEAP message %s: unable to construct2\n", type->type_name);
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create_request(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const char *version, const void *params)
+{
+	struct ast_aeap_message *msg;
+
+	msg = ast_aeap_message_create2(type, "request", name, id, version, NULL, NULL, params);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (!id && ast_aeap_message_id_generate(msg)) {
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+struct ast_aeap_message *ast_aeap_message_create_response(const struct ast_aeap_message_type *type,
+	const char *name, const char *id, const char *version, const void *params)
+{
+	return ast_aeap_message_create2(type, "response", name, id, version, NULL, NULL, params);
+}
+
+struct ast_aeap_message *ast_aeap_message_deserialize(const struct ast_aeap_message_type *type,
+	const void *buf, intmax_t size)
+{
+	struct ast_aeap_message *msg;
+
+	ast_assert(type != NULL);
+	ast_assert(type->deserialize != NULL);
+
+	msg = ast_aeap_message_create1(type, NULL);
+	if (!msg) {
+		return NULL;
+	}
+
+	if (type->deserialize(msg, buf, size)) {
+		ao2_ref(msg, -1);
+		return NULL;
+	}
+
+	return msg;
+}
+
+int ast_aeap_message_serialize(const struct ast_aeap_message *message,
+	void **buf, intmax_t *size)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->serialize ? message->type->serialize(message, buf, size) : 0;
+}
+
+const char *ast_aeap_message_id(const struct ast_aeap_message *message)
+{
+	const char *id = NULL;
+
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	if (message->type->id) {
+		id = message->type->id(message);
+	}
+
+	return id ? id : "";
+}
+
+int ast_aeap_message_id_set(struct ast_aeap_message *message, const char *id)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->id_set ? message->type->id_set(message, id) : 0;
+}
+
+int ast_aeap_message_id_generate(struct ast_aeap_message *message)
+{
+	char uuid_str[AST_UUID_STR_LEN];
+
+	ast_uuid_generate_str(uuid_str, sizeof(uuid_str));
+	if (strlen(uuid_str) != (AST_UUID_STR_LEN - 1)) {
+		ast_log(LOG_ERROR, "AEAP message %s failed to generate UUID for message '%s'",
+			message->type->type_name, ast_aeap_message_name(message));
+		return -1;
+	}
+
+	return ast_aeap_message_id_set(message, uuid_str);
+}
+
+const char *ast_aeap_message_name(const struct ast_aeap_message *message)
+{
+	const char *name = NULL;
+
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	if (message->type->name) {
+		name = message->type->name(message);
+	}
+
+	return name ? name : "";
+}
+
+int ast_aeap_message_is_named(const struct ast_aeap_message *message, const char *name)
+{
+	return name ? !strcasecmp(ast_aeap_message_name(message), name) : 0;
+}
+
+void *ast_aeap_message_data(struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->data ? message->type->data(message) : NULL;
+}
+
+int ast_aeap_message_is_request(const struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->is_request ? message->type->is_request(message) : 0;
+}
+
+int ast_aeap_message_is_response(const struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->is_response ? message->type->is_response(message) : 0;
+}
+
+int ast_aeap_message_is_error(const struct ast_aeap_message *message)
+{
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	return message->type->is_error ? message->type->is_error(message) : 0;
+}
+
+const char *ast_aeap_message_error_msg(const struct ast_aeap_message *message)
+{
+	const char *error_msg = NULL;
+
+	ast_assert(message != NULL);
+	ast_assert(message->type != NULL);
+
+	if (message->type->error_msg) {
+		error_msg = message->type->error_msg(message);
+	}
+
+	return error_msg ? error_msg : "";
+}
diff --git a/res/res_aeap/message_json.c b/res/res_aeap/message_json.c
new file mode 100644
index 0000000..aac499c
--- /dev/null
+++ b/res/res_aeap/message_json.c
@@ -0,0 +1,209 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/json.h"
+
+#include "asterisk/res_aeap_message.h"
+
+#define JSON_MSG(lvalue, rvalue) struct message_json *lvalue = \
+		((struct message_json *)rvalue)
+
+/*!
+ * \brief Asterisk external application JSON message type
+ */
+struct message_json {
+	/*! The base message type (must be first) */
+	struct ast_aeap_message base;
+	/*! Underlying JSON data structure */
+	struct ast_json *json;
+};
+
+static int message_json_construct1(struct ast_aeap_message *self, const void *params)
+{
+	JSON_MSG(msg, self);
+
+	msg->json = ast_json_ref((struct ast_json *)params) ?: ast_json_object_create();
+
+	return msg->json ? 0 : -1;
+}
+
+static int message_json_construct2(struct ast_aeap_message *self, const char *msg_type, const char *name,
+	const char *id, const char *version, const char *status, const char *error_msg, const void *params)
+{
+	struct ast_json *msg_data;
+	int res;
+
+	msg_data = ast_json_pack("{s:s,s:s*,s:s*,s:s*,s:s*}",
+		msg_type, name,
+		"id", id,
+		"version", version,
+		"status", status,
+		"error_msg", error_msg);
+
+	if (!msg_data) {
+		ast_log(LOG_ERROR, "AEAP message json: failed to create data for '%s: %s'", msg_type, name);
+		return -1;
+	}
+
+	if (params && ast_json_object_update(msg_data, (struct ast_json *)params)) {
+		ast_log(LOG_ERROR, "AEAP message json: failed to update data for '%s: %s'", msg_type, name);
+		ast_json_unref(msg_data);
+		return -1;
+	}
+
+	res = message_json_construct1(self, msg_data);
+	ast_json_unref(msg_data);
+	return res;
+}
+
+static void message_json_destruct(struct ast_aeap_message *self)
+{
+	JSON_MSG(msg, self);
+
+	ast_json_unref(msg->json);
+}
+
+static int message_json_deserialize(struct ast_aeap_message *self, const void *buf, intmax_t size)
+{
+	JSON_MSG(msg, self);
+
+	msg->json = ast_json_load_buf(buf, size, NULL);
+
+	return msg->json ? 0 : -1;
+}
+
+static int message_json_serialize(const struct ast_aeap_message *self, void **buf, intmax_t *size)
+{
+	const JSON_MSG(msg, self);
+
+	*buf = ast_json_dump_string(msg->json);
+	if (!*buf) {
+		*size = 0;
+		return -1;
+	}
+
+	*size = strlen(*buf);
+
+	return 0;
+}
+
+static const char *message_json_id(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_string_get(msg->json, "id");
+}
+
+static int message_json_id_set(struct ast_aeap_message *self, const char *id)
+{
+	JSON_MSG(msg, self);
+
+	if (ast_json_object_set(msg->json, "id", ast_json_string_create(id))) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static const char *message_json_name(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+	struct ast_json_iter *iter;
+
+	iter = ast_json_object_iter_at(msg->json, "response");
+	if (!iter) {
+		iter = ast_json_object_iter_at(msg->json, "request");
+	}
+
+	return iter ? ast_json_string_get(ast_json_object_iter_value(iter)) : "";
+}
+
+static void *message_json_data(struct ast_aeap_message *self)
+{
+	JSON_MSG(msg, self);
+
+	return msg->json;
+}
+
+static int message_json_is_request(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_iter_at(msg->json, "request") != NULL;
+}
+
+static int message_json_is_response(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_iter_at(msg->json, "response") != NULL;
+}
+
+static int message_json_is_error(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+	const char *status;
+
+	status = ast_json_object_string_get(msg->json, "status");
+
+	return status ? !strcasecmp(status, "error") : 0;
+}
+
+static const char *message_json_error_msg(const struct ast_aeap_message *self)
+{
+	const JSON_MSG(msg, self);
+
+	return ast_json_object_string_get(msg->json, "error_msg");
+}
+
+static const struct ast_aeap_message_type message_type_json = {
+	.type_size = sizeof(struct message_json),
+	.type_name = "json",
+	.serial_type = AST_AEAP_DATA_TYPE_STRING,
+	.construct1 = message_json_construct1,
+	.construct2 = message_json_construct2,
+	.destruct = message_json_destruct,
+	.deserialize = message_json_deserialize,
+	.serialize = message_json_serialize,
+	.id = message_json_id,
+	.id_set = message_json_id_set,
+	.name = message_json_name,
+	.data = message_json_data,
+	.is_request = message_json_is_request,
+	.is_response = message_json_is_response,
+	.is_error = message_json_is_error,
+	.error_msg = message_json_error_msg,
+};
+
+const struct ast_aeap_message_type *ast_aeap_message_type_json = &message_type_json;
+
+struct ast_aeap_message *ast_aeap_message_json_create_request(const char *name,
+	const char *id, const char *version, const struct ast_json *json)
+{
+	return ast_aeap_message_create_request(ast_aeap_message_type_json, name, id,
+		version, json);
+}
+
+struct ast_aeap_message *ast_aeap_message_json_create_response(const char *name,
+	const char *id, const char *version, const struct ast_json *json)
+{
+	return ast_aeap_message_create_response(ast_aeap_message_type_json, name, id,
+		version, json);
+}
diff --git a/res/res_aeap/transaction.c b/res/res_aeap/transaction.c
new file mode 100644
index 0000000..3557aef
--- /dev/null
+++ b/res/res_aeap/transaction.c
@@ -0,0 +1,269 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/sched.h"
+#include "asterisk/utils.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#include "general.h"
+#include "logger.h"
+#include "transaction.h"
+
+struct aeap_transaction {
+	/*! Pointer back to owner object */
+	struct ast_aeap *aeap;
+	/*! The container this transaction is in */
+	struct ao2_container *container;
+	/*! Scheduler ID message timeout */
+	int sched_id;
+	/*! Whether or not the handler has been executed */
+	int handled;
+	/*! Used to sync matching received messages */
+	ast_cond_t handled_cond;
+	/*! The result of this transaction */
+	int result;
+	/*! The timeout data */
+	struct ast_aeap_tsx_params params;
+	/*! The transaction identifier */
+	char id[0];
+};
+
+/*! \brief Number of transaction buckets */
+#define AEAP_TRANSACTION_BUCKETS 11
+
+AO2_STRING_FIELD_HASH_FN(aeap_transaction, id);
+AO2_STRING_FIELD_CMP_FN(aeap_transaction, id);
+
+int aeap_transaction_cancel_timer(struct aeap_transaction *tsx)
+{
+	if (tsx && tsx->sched_id != -1) {
+		AST_SCHED_DEL_UNREF(aeap_sched_context(), tsx->sched_id, ao2_ref(tsx, -1));
+		return tsx->sched_id != -1;
+	}
+
+	return 0;
+}
+
+void aeap_transaction_params_cleanup(struct ast_aeap_tsx_params *params)
+{
+	ao2_cleanup(params->msg);
+
+	if (params->obj_cleanup) {
+		params->obj_cleanup(params->obj);
+	}
+}
+
+static void transaction_destructor(void *obj)
+{
+	struct aeap_transaction *tsx = obj;
+
+	/* Ensure timer is canceled */
+	aeap_transaction_cancel_timer(tsx);
+
+	aeap_transaction_params_cleanup(&tsx->params);
+
+	ao2_cleanup(tsx->container);
+
+	ast_cond_destroy(&tsx->handled_cond);
+}
+
+static struct aeap_transaction *transaction_create(const char *id,
+	struct ast_aeap_tsx_params *params, struct ast_aeap *aeap)
+{
+	struct aeap_transaction *tsx;
+
+	if (!id) {
+		aeap_error(aeap, "transaction", "missing transaction id");
+		aeap_transaction_params_cleanup(params);
+		return NULL;
+	}
+
+	tsx = ao2_alloc(sizeof(*tsx) + strlen(id), transaction_destructor);
+	if (!tsx) {
+		aeap_error(aeap, "transaction", "unable to create for '%s'", id);
+		aeap_transaction_params_cleanup(params);
+		return NULL;
+	}
+
+	strcpy(tsx->id, id); /* safe */
+	tsx->sched_id = -1;
+
+	ast_cond_init(&tsx->handled_cond, NULL);
+
+	/*
+	 * Currently, transactions, and their lifetimes are fully managed by the given 'aeap'
+	 * object, so do not bump its reference here as we want the 'aeap' object to stop
+	 * transactions and not transactions potentially stopping the 'aeap' object.
+	 */
+	tsx->aeap = aeap;
+	tsx->params = *params;
+
+	return tsx;
+}
+
+static void transaction_end(struct aeap_transaction *tsx, int timed_out, int result)
+{
+	if (!tsx) {
+		return;
+	}
+
+	ao2_lock(tsx);
+
+	tsx->result = result;
+
+	if (tsx->container) {
+		ao2_unlink(tsx->container, tsx);
+		ao2_ref(tsx->container, -1);
+		tsx->container = NULL;
+	}
+
+	if (!timed_out) {
+		aeap_transaction_cancel_timer(tsx);
+	} else if (tsx->sched_id != -1) {
+		tsx->sched_id = -1;
+	}
+
+	if (!tsx->handled) {
+		if (timed_out) {
+			if (tsx->params.on_timeout) {
+				tsx->params.on_timeout(tsx->aeap, tsx->params.msg, tsx->params.obj);
+			} else {
+				aeap_error(tsx->aeap, "transaction", "message '%s' timed out",
+					ast_aeap_message_name(tsx->params.msg));
+			}
+		}
+
+		tsx->handled = 1;
+		ast_cond_signal(&tsx->handled_cond);
+	}
+
+	ao2_unlock(tsx);
+
+	ao2_ref(tsx, -1);
+}
+
+static int transaction_raise_timeout(const void *data)
+{
+	/* Ref added added at timer creation removed in end call */
+	transaction_end((struct aeap_transaction *)data, 1, -1);
+
+	return 0;
+}
+
+static int transaction_sched_timer(struct aeap_transaction *tsx)
+{
+	if (tsx->params.timeout <= 0 || tsx->sched_id != -1) {
+		return 0;
+	}
+
+	tsx->sched_id = ast_sched_add(aeap_sched_context(), tsx->params.timeout,
+			transaction_raise_timeout, ao2_bump(tsx));
+	if (tsx->sched_id == -1) {
+		aeap_error(tsx->aeap, "transaction", "unable to schedule timeout for '%s'", tsx->id);
+		ao2_ref(tsx, -1);
+		return -1;
+	}
+
+	return 0;
+}
+
+static void transaction_wait(struct aeap_transaction *tsx)
+{
+	ao2_lock(tsx);
+
+	while (!tsx->handled) {
+		ast_cond_wait(&tsx->handled_cond, ao2_object_get_lockaddr(tsx));
+	}
+
+	ao2_unlock(tsx);
+}
+
+int aeap_transaction_start(struct aeap_transaction *tsx)
+{
+	if (transaction_sched_timer(tsx)) {
+		return -1;
+	}
+
+	if (tsx->params.wait) {
+		/* Wait until transaction completes, or times out */
+		transaction_wait(tsx);
+	}
+
+	return 0;
+}
+
+struct aeap_transaction *aeap_transaction_get(struct ao2_container *transactions, const char *id)
+{
+	return ao2_find(transactions, id, OBJ_SEARCH_KEY);
+}
+
+void aeap_transaction_end(struct aeap_transaction *tsx, int result)
+{
+	transaction_end(tsx, 0, result);
+}
+
+int aeap_transaction_result(struct aeap_transaction *tsx)
+{
+	return tsx->result;
+}
+
+void *aeap_transaction_user_obj(struct aeap_transaction *tsx)
+{
+	return tsx->params.obj;
+}
+
+struct aeap_transaction *aeap_transaction_create_and_add(struct ao2_container *transactions,
+	const char *id, struct ast_aeap_tsx_params *params, struct ast_aeap *aeap)
+{
+	struct aeap_transaction *tsx;
+
+	tsx = transaction_create(id, params, aeap);
+	if (!tsx) {
+		return NULL;
+	}
+
+	if (!ao2_link(transactions, tsx)) {
+		aeap_error(tsx->aeap, "transaction", "unable to add '%s' to container", id);
+		ao2_ref(tsx, -1);
+		return NULL;
+	}
+
+	tsx->container = transactions;
+	ao2_ref(tsx->container, 1);
+
+	return tsx;
+}
+
+struct ao2_container *aeap_transactions_create(void)
+{
+	struct ao2_container *transactions;
+
+	transactions = ao2_container_alloc_hash(AO2_ALLOC_OPT_LOCK_MUTEX, 0, AEAP_TRANSACTION_BUCKETS,
+		aeap_transaction_hash_fn, NULL, aeap_transaction_cmp_fn);
+	if (!transactions) {
+		ast_log(LOG_ERROR, "AEAP transaction: unable to create container\n");
+		return NULL;
+	}
+
+	return transactions;
+}
diff --git a/res/res_aeap/transaction.h b/res/res_aeap/transaction.h
new file mode 100644
index 0000000..db13df1
--- /dev/null
+++ b/res/res_aeap/transaction.h
@@ -0,0 +1,112 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_TRANSACTION_H
+#define RES_AEAP_TRANSACTION_H
+
+#include "asterisk/res_aeap.h"
+
+struct ao2_container;
+struct ast_aeap_tsx_params;
+struct aeap_transaction;
+
+/*!
+ * \brief Create an Asterisk external application transactions container
+ *
+ * \returns A transaction object, or NULL on error
+ */
+struct ao2_container *aeap_transactions_create(void);
+
+/*!
+ * \brief Create a transaction object, and add it to the given container
+ *
+ * \param transactions A transactions container
+ * \param id An id to use for the transaction
+ * \param params Transaction parameters
+ * \param aeap The aeap object that "owns" this transaction
+ *
+ * \returns 0 if successfully create and added, -1 on error
+ */
+struct aeap_transaction *aeap_transaction_create_and_add(struct ao2_container *transactions,
+	const char *id, struct ast_aeap_tsx_params *params, struct ast_aeap *aeap);
+
+/*!
+ * \brief Clean up parameter references, and possibly call optional user object cleanup
+ *
+ * \param params Transaction parameters
+ */
+void aeap_transaction_params_cleanup(struct ast_aeap_tsx_params *params);
+
+/*!
+ * \brief Retrieve a transaction for the id from the container
+ *
+ * \param transactions A transactions container
+ * \param id A transaction id
+ *
+ * \returns an AEAP transaction object, NULL if no transaction is found
+ */
+struct aeap_transaction *aeap_transaction_get(struct ao2_container *transactions,
+	const char *id);
+
+/*!
+ * \brief Start the transaction
+ *
+ * \param tsx The transaction to initiate
+ *
+ * \returns 0 if successfully raised, and handled. Otherwise non zero.
+ */
+int aeap_transaction_start(struct aeap_transaction *tsx);
+
+/*!
+ * \brief End a transaction, and remove it from the given container
+ *
+ * \param tsx A transaction to end
+ * \param result A result to give to the transaction
+ */
+void aeap_transaction_end(struct aeap_transaction *tsx, int result);
+
+/*!
+ * \brief Get a transaction's result
+ *
+ * \param tsx A transaction object
+ *
+ * \returns The transaction result
+ */
+int aeap_transaction_result(struct aeap_transaction *tsx);
+
+/*!
+ * \brief Cancel the transaction timer
+ *
+ * Stops the transaction timer, but does not end/stop the transaction itself
+ *
+ * \param transaction A transaction to cancel the timer on
+ *
+ * \returns 0 if canceled, non zero otherwise
+ */
+int aeap_transaction_cancel_timer(struct aeap_transaction *tsx);
+
+/*!
+ * \brief Retrieve the user object associated with the transaction
+ *
+ * \param transaction A transaction object
+ *
+ * \returns A user object, or NULL if non associated
+ */
+void *aeap_transaction_user_obj(struct aeap_transaction *tsx);
+
+#endif /* RES_AEAP_TRANSACTION_H */
diff --git a/res/res_aeap/transport.c b/res/res_aeap/transport.c
new file mode 100644
index 0000000..6476779
--- /dev/null
+++ b/res/res_aeap/transport.c
@@ -0,0 +1,155 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/utils.h"
+
+#include "logger.h"
+#include "transport.h"
+#include "transport_websocket.h"
+
+struct aeap_transport *aeap_transport_create(const char *type)
+{
+	struct aeap_transport *transport = NULL;
+
+	if (!strncasecmp(type, "ws", 2)) {
+		transport = (struct aeap_transport *)aeap_transport_websocket_create();
+	}
+
+	if (!transport) {
+		ast_log(LOG_ERROR, "AEAP transport: failed to create for type '%s'\n", type);
+		return NULL;
+	}
+
+	ast_mutex_init(&transport->read_lock);
+	ast_mutex_init(&transport->write_lock);
+
+	transport->connected = 0;
+
+	return transport;
+}
+
+int aeap_transport_connect(struct aeap_transport *transport, const char *url, const char *protocol)
+{
+	int res;
+
+	SCOPED_MUTEX(rlock, &transport->read_lock);
+	SCOPED_MUTEX(wlock, &transport->write_lock);
+
+	if (aeap_transport_is_connected(transport)) {
+		return 0;
+	}
+
+	res = transport->vtable->connect(transport, url, protocol);
+	if (!res) {
+		transport->connected = 1;
+	}
+
+	return res;
+}
+
+struct aeap_transport *aeap_transport_create_and_connect(const char *type,
+	const char *url, const char *protocol)
+{
+	struct aeap_transport *transport = aeap_transport_create(type);
+
+	if (!transport) {
+		return NULL;
+	}
+
+	if (aeap_transport_connect(transport, url, protocol)) {
+		aeap_transport_destroy(transport);
+		return NULL;
+	}
+
+	return transport;
+}
+
+int aeap_transport_is_connected(struct aeap_transport *transport)
+{
+	/*
+	 * Avoid using a lock to 'read' the 'connected' variable in order to
+	 * keep things slightly more efficient.
+	 */
+	return ast_atomic_fetch_add(&transport->connected, 0, __ATOMIC_RELAXED);
+}
+
+int aeap_transport_disconnect(struct aeap_transport *transport)
+{
+	int res;
+
+	SCOPED_MUTEX(rlock, &transport->read_lock);
+	SCOPED_MUTEX(wlock, &transport->write_lock);
+
+	if (!aeap_transport_is_connected(transport)) {
+		return 0;
+	}
+
+	res = transport->vtable->disconnect(transport);
+
+	/*
+	 * Even though the transport is locked here use atomics to set the value of
+	 * 'connected' since it's possible the variable is being 'read' by another
+	 * thread via the 'is_connected' call.
+	 */
+	ast_atomic_fetch_sub(&transport->connected, 1, __ATOMIC_RELAXED);
+
+	return res;
+}
+
+void aeap_transport_destroy(struct aeap_transport *transport)
+{
+	if (!transport) {
+		return;
+	}
+
+	/* Ensure an orderly disconnect occurs before final destruction */
+	aeap_transport_disconnect(transport);
+
+	transport->vtable->destroy(transport);
+
+	ast_mutex_destroy(&transport->read_lock);
+	ast_mutex_destroy(&transport->write_lock);
+
+	ast_free(transport);
+}
+
+intmax_t aeap_transport_read(struct aeap_transport *transport, void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE *rtype)
+{
+	SCOPED_MUTEX(lock, &transport->read_lock);
+
+	if (!aeap_transport_is_connected(transport)) {
+		return 0;
+	}
+
+	return transport->vtable->read(transport, buf, size, rtype);
+}
+
+intmax_t aeap_transport_write(struct aeap_transport *transport, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE wtype)
+{
+	SCOPED_MUTEX(lock, &transport->write_lock);
+
+	if (!aeap_transport_is_connected(transport)) {
+		return -1;
+	}
+
+	return transport->vtable->write(transport, buf, size, wtype);
+}
diff --git a/res/res_aeap/transport.h b/res/res_aeap/transport.h
new file mode 100644
index 0000000..556f23d
--- /dev/null
+++ b/res/res_aeap/transport.h
@@ -0,0 +1,202 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_TRANSPORT_H
+#define RES_AEAP_TRANSPORT_H
+
+#include <stdint.h>
+
+#include "asterisk/res_aeap.h"
+
+struct aeap_transport;
+
+/*!
+ * \brief Asterisk external application transport virtual table
+ *
+ * Callbacks to be implemented by "derived" transports
+ */
+struct aeap_transport_vtable {
+	/*!
+	 * \brief Connect a transport
+	 *
+	 * \param self The transport object
+	 * \param url The URL to connect to
+	 * \param protocol The connection protocol to use if applicable
+	 *
+	 * \returns 0 on success, or -1 on error
+	 */
+	int (*connect)(struct aeap_transport *self, const char *url, const char *protocol);
+
+	/*!
+	 * \brief Disconnect a transport
+	 *
+	 * \param self The transport object
+	 *
+	 * \returns 0 on success, or -1 on error
+	 */
+	int (*disconnect)(struct aeap_transport *self);
+
+	/*!
+	 * \brief Destroy a transport
+	 *
+	 * \param self The transport object
+	 */
+	void (*destroy)(struct aeap_transport *self);
+
+	/*!
+	 * \brief Read data from a transport
+	 *
+	 * \param self The transport object
+	 * \param buf The buffer data is read read into
+	 * \param size The size of the given data buffer
+	 * \param rtype [out] The type of data read
+	 *
+	 * \returns Total number of bytes read, or less than zero on error
+	 */
+	intmax_t (*read)(struct aeap_transport *self, void *buf, intmax_t size,
+		enum AST_AEAP_DATA_TYPE *rtype);
+
+	/*!
+	 * \brief Write data to a transport
+	 *
+	 * \param self The transport object
+	 * \param buf The data to write
+	 * \param size The size of data to write
+	 * \param wtype The type of data to write
+	 *
+	 * \returns Total number of bytes written, or less than zero on error
+	 */
+	intmax_t (*write)(struct aeap_transport *self, const void *buf, intmax_t size,
+		enum AST_AEAP_DATA_TYPE wtype);
+};
+
+/*!
+ * \brief Asterisk external application transport structure to be
+ *        "derived" by specific transport implementation types
+ *
+ * Transports are assumed to support simultaneous reading and writing,
+ * thus separate read and write locks. A transport type not supporting
+ * such can simply apply the opposing lock during a read or write, i.e.
+ * lock the write lock during a read and vice versa.
+ */
+struct aeap_transport {
+	/*! Transport virtual table */
+	struct aeap_transport_vtable *vtable;
+	/*! Whether or not the transport is connected */
+	unsigned int connected;
+	/*! Lock used when reading */
+	ast_mutex_t read_lock;
+	/*! Lock used when writing */
+	ast_mutex_t write_lock;
+};
+
+/*!
+ * \brief Create an Asterisk external application transport
+ *
+ * \param type The type of transport to create
+ *
+ * \returns An Asterisk external application transport, or NULL on error
+ */
+struct aeap_transport *aeap_transport_create(const char *type);
+
+/*!
+ * \brief Connect a transport
+ *
+ * \param transport The transport to connect
+ * \param url The URL to connect to
+ * \param protocol The connection protocol to use if applicable
+ *
+ * \returns 0 on success, or -1 on error
+ */
+int aeap_transport_connect(struct aeap_transport *transport, const char *url, const char *protocol);
+
+/*!
+ * \brief Create an Asterisk external application transport, and connect it
+ *
+ * \param type The type of transport to create
+ * \param url The URL to connect to
+ * \param protocol The connection protocol to use if applicable
+ *
+ * \returns An Asterisk external application transport, or NULL on error
+ */
+struct aeap_transport *aeap_transport_create_and_connect(const char* type,
+	const char *url, const char *protocol);
+
+/*!
+ * \brief Disconnect a transport
+ *
+ * \note Locks both the transport's read and write locks before calling transport
+ *       instance's disconnect, and unlocks both before returning.
+ *
+ * \param transport The transport to disconnect
+ *
+ * \returns 0 on success, or -1 on error
+ */
+int aeap_transport_disconnect(struct aeap_transport *transport);
+
+/*!
+ * \brief Whether or not the transport is in a connected state
+ *
+ * \param transport The transport object
+ *
+ * \returns True if connected, false otherwise
+ */
+int aeap_transport_is_connected(struct aeap_transport *transport);
+
+/*!
+ * \brief Destroy a transport
+ *
+ * \param transport The transport to destroy
+ *
+ * \returns 0 on success, or -1 on error
+ */
+void aeap_transport_destroy(struct aeap_transport *transport);
+
+/*!
+ * \brief Read data from the transport
+ *
+ * \note Locks transport's read lock before calling transport instance's
+ *       read, and unlocks it before returning.
+ *
+ * \param transport The transport to read from
+ * \param buf The buffer data is read into
+ * \param size The size of data given data buffer
+ * \param rtype [out] The type of data read
+ *
+ * \returns Total number of bytes read, or less than zero on error
+ */
+intmax_t aeap_transport_read(struct aeap_transport *transport, void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE *rtype);
+
+/*!
+ * \brief Write data to the transport
+ *
+ * \note Locks transport's write lock before calling transport instance's
+ *       write, and unlocks it before returning.
+ *
+ * \param transport The transport to write to
+ * \param buf The data to write
+ * \param size The size of data to write
+ * \param wtype The type of data to write
+ *
+ * \returns Total number of bytes written, or less than zero on error
+ */
+intmax_t aeap_transport_write(struct aeap_transport *transport, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE wtype);
+
+#endif /* RES_AEAP_TRANSPORT_H */
diff --git a/res/res_aeap/transport_websocket.c b/res/res_aeap/transport_websocket.c
new file mode 100644
index 0000000..638b674
--- /dev/null
+++ b/res/res_aeap/transport_websocket.c
@@ -0,0 +1,242 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/http_websocket.h"
+#include "asterisk/utils.h"
+
+#include "logger.h"
+#include "transport.h"
+#include "transport_websocket.h"
+
+#define log_error(obj, fmt, ...) aeap_error(obj, "websocket", fmt, ##__VA_ARGS__)
+
+struct aeap_transport_websocket {
+	/*! Derive from base transport (must be first attribute) */
+	struct aeap_transport base;
+	/*! The underlying websocket */
+	struct ast_websocket *ws;
+};
+
+static int websocket_connect(struct aeap_transport *self, const char *url, const char *protocol)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+	enum ast_websocket_result ws_result;
+
+	transport->ws = ast_websocket_client_create(url, protocol, NULL, &ws_result);
+	if (ws_result != WS_OK) {
+		log_error(self, "connect failure (%d)", (int)ws_result);
+		return -1;
+	}
+
+	return 0;
+}
+
+static int websocket_disconnect(struct aeap_transport *self)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+
+	if (transport->ws) {
+		ast_websocket_unref(transport->ws);
+		transport->ws = NULL;
+	}
+
+	return 0;
+}
+
+static void websocket_destroy(struct aeap_transport *self)
+{
+	/*
+	 * Disconnect takes care of cleaning up the websocket. Note, disconnect
+	 * was called by the base/dispatch interface prior to calling this
+	 * function so nothing to do here.
+	 */
+}
+
+static intmax_t websocket_read(struct aeap_transport *self, void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE *rtype)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+
+	char *payload;
+	uint64_t bytes_read = 0;
+	uint64_t total_bytes_read = 0;
+	enum ast_websocket_opcode opcode;
+	int fragmented = 0;
+
+	*rtype = AST_AEAP_DATA_TYPE_NONE;
+
+	if (ast_websocket_fd(transport->ws) < 0) {
+		log_error(self, "unavailable for reading");
+		/* Ensure this transport is in a disconnected state */
+		aeap_transport_disconnect(self);
+		return -1;
+	}
+
+	/*
+	 * This function is called with the read_lock locked. However, the lock needs to be
+	 * unlocked while waiting for input otherwise a deadlock can occur during disconnect
+	 * (disconnect attempts to grab the lock but can't because read holds it here). So
+	 * unlock it prior to waiting.
+	 */
+	ast_mutex_unlock(&transport->base.read_lock);
+	if (ast_websocket_wait_for_input(transport->ws, -1) <= 0) {
+		ast_mutex_lock(&transport->base.read_lock);
+		log_error(self, "poll failure: %s", strerror(errno));
+		/* Ensure this transport is in a disconnected state */
+		aeap_transport_disconnect(self);
+		return -1;
+	}
+	ast_mutex_lock(&transport->base.read_lock);
+
+	if (!transport->ws) {
+		/*
+		 * It's possible the transport was told to disconnect while waiting for input.
+		 * If so then the websocket will be NULL, so we don't want to continue.
+		 */
+		return 0;
+	}
+
+	do {
+		if (ast_websocket_read(transport->ws, &payload, &bytes_read, &opcode,
+				&fragmented) != 0) {
+			log_error(self, "read failure (%d): %s", opcode, strerror(errno));
+			return -1;
+		}
+
+		if (!bytes_read) {
+			continue;
+		}
+
+		if (total_bytes_read + bytes_read > size) {
+			log_error(self, "attempted to read too many bytes into (%jd) sized buffer", size);
+			return -1;
+		}
+
+		memcpy(buf + total_bytes_read, payload, bytes_read);
+		total_bytes_read += bytes_read;
+
+	} while (opcode == AST_WEBSOCKET_OPCODE_CONTINUATION);
+
+	switch (opcode) {
+	case AST_WEBSOCKET_OPCODE_CLOSE:
+		log_error(self, "closed");
+		return -1;
+	case AST_WEBSOCKET_OPCODE_BINARY:
+		*rtype = AST_AEAP_DATA_TYPE_BINARY;
+		break;
+	case AST_WEBSOCKET_OPCODE_TEXT:
+		*rtype = AST_AEAP_DATA_TYPE_STRING;
+
+		/* Append terminator, but check for overflow first */
+		if (total_bytes_read == size) {
+			log_error(self, "unable to write string terminator");
+			return -1;
+		}
+
+		*((char *)(buf + total_bytes_read)) = '\0';
+		break;
+	default:
+		/* Ignore all other message types */
+		return 0;
+	}
+
+	return total_bytes_read;
+}
+
+static intmax_t websocket_write(struct aeap_transport *self, const void *buf, intmax_t size,
+	enum AST_AEAP_DATA_TYPE wtype)
+{
+	struct aeap_transport_websocket *transport = (struct aeap_transport_websocket *)self;
+	intmax_t res = 0;
+
+	switch (wtype) {
+	case AST_AEAP_DATA_TYPE_BINARY:
+		res = ast_websocket_write(transport->ws, AST_WEBSOCKET_OPCODE_BINARY,
+			(char *)buf, size);
+		break;
+	case AST_AEAP_DATA_TYPE_STRING:
+		res = ast_websocket_write(transport->ws, AST_WEBSOCKET_OPCODE_TEXT,
+			(char *)buf, size);
+		break;
+	default:
+		break;
+	}
+
+	if (res < 0) {
+		log_error(self, "problem writing to websocket (closed)");
+
+		/*
+		 * If the underlying socket is closed then ensure the
+		 * transport is in a disconnected state as well.
+		 */
+		aeap_transport_disconnect(self);
+
+		return res;
+	}
+
+	return size;
+}
+
+static struct aeap_transport_vtable *transport_websocket_vtable(void)
+{
+	static struct aeap_transport_vtable websocket_vtable = {
+		.connect = websocket_connect,
+		.disconnect = websocket_disconnect,
+		.destroy = websocket_destroy,
+		.read = websocket_read,
+		.write = websocket_write,
+	};
+
+	return &websocket_vtable;
+}
+
+/*!
+ * \brief Initialize a transport websocket object, and set its virtual table
+ *
+ * \param transport The transport to initialize
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int transport_websocket_init(struct aeap_transport_websocket *transport)
+{
+	transport->ws = NULL;
+
+	((struct aeap_transport *)transport)->vtable = transport_websocket_vtable();
+
+	return 0;
+}
+
+struct aeap_transport_websocket *aeap_transport_websocket_create(void)
+{
+	struct aeap_transport_websocket *transport;
+
+	transport = ast_calloc(1, sizeof(*transport));
+	if (!transport) {
+		ast_log(LOG_ERROR, "AEAP websocket: unable to create transport websocket");
+		return NULL;
+	}
+
+	if (transport_websocket_init(transport)) {
+		ast_free(transport);
+		return NULL;
+	}
+
+	return transport;
+}
diff --git a/res/res_aeap/transport_websocket.h b/res/res_aeap/transport_websocket.h
new file mode 100644
index 0000000..d72657e
--- /dev/null
+++ b/res/res_aeap/transport_websocket.h
@@ -0,0 +1,34 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef RES_AEAP_TRANSPORT_WEBSOCKET_H
+#define RES_AEAP_TRANSPORT_WEBSOCKET_H
+
+/*!
+ * \brief Asterisk external application protocol websocket transport
+ */
+struct aeap_transport_websocket;
+
+/*!
+ * \brief Creates (heap allocated), and initializes a transport websocket
+ *
+ * \returns A transport websocket object, or NULL on error
+ */
+struct aeap_transport_websocket *aeap_transport_websocket_create(void);
+
+#endif /* RES_AEAP_TRANSPORT_WEBSOCKET_H */
diff --git a/res/res_speech_aeap.c b/res/res_speech_aeap.c
new file mode 100644
index 0000000..5810e2e
--- /dev/null
+++ b/res/res_speech_aeap.c
@@ -0,0 +1,744 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*! \file
+ *
+ * \brief Asterisk External Application Speech Engine
+ *
+ */
+
+/*** MODULEINFO
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/astobj2.h"
+#include "asterisk/config.h"
+#include "asterisk/format.h"
+#include "asterisk/json.h"
+#include "asterisk/module.h"
+#include "asterisk/speech.h"
+#include "asterisk/sorcery.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#define SPEECH_AEAP_VERSION "1.0"
+
+#define log_error(obj, fmt, ...) \
+	ast_log(LOG_ERROR, "AEAP speech (%p): " fmt "\n", obj, ##__VA_ARGS__)
+
+/*!
+ * \internal
+ * \brief Create, and send a request to the external application
+ *
+ * Create, then sends a request to an Asterisk external application, and then blocks
+ * until a response is received or a time out occurs. Since this method waits until
+ * receiving a response the returned result is guaranteed to be pass/fail based upon
+ * a response handler's result.
+ *
+ * \param name The name of the request to send
+ * \param json The core json request data
+ * \param data Optional user data to associate with request/response
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_send_request(struct ast_aeap *aeap, const char *name,
+	struct ast_json *json, void *obj)
+{
+	/*
+	 * Wait for a response. Also since we're blocking,
+	 * data is expected to be on the stack so no cleanup required.
+	 */
+	struct ast_aeap_tsx_params tsx_params = {
+		.timeout = 1000,
+		.wait = 1,
+		.obj = obj,
+	};
+
+	/* "steals" the json ref */
+	tsx_params.msg = ast_aeap_message_json_create_request(name, NULL, SPEECH_AEAP_VERSION, json);
+	if (!tsx_params.msg) {
+		return -1;
+	}
+
+	/* Send "steals" the json msg ref */
+	return ast_aeap_send_msg_tsx(aeap, &tsx_params);
+}
+
+/*!
+ * \internal
+ * \brief Create, and send a "get" request to an external application
+ *
+ * Basic structure of the JSON message to send:
+ *
+ *   { param: [<param>, ...] }
+ *
+ * \param speech The speech engine
+ * \param param The name of the parameter to retrieve
+ * \param data User data passed to the response handler
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_get(struct ast_speech *speech, const char *param, void *data)
+{
+	if (!param) {
+		return -1;
+	}
+
+	/* send_request handles json ref */
+	return speech_aeap_send_request(speech->data,
+		"get", ast_json_pack("{s:[s]}", "params", param), data);
+}
+
+struct speech_param {
+	const char *name;
+	const char *value;
+};
+
+/*!
+ * \internal
+ * \brief Create, and send a "set" request to an external application
+ *
+ * Basic structure of the JSON message to send:
+ *
+ *   { params: { <name> : <value> }  }
+ *
+ * \param speech The speech engine
+ * \param name The name of the parameter to set
+ * \param value The value of the parameter to set
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_set(struct ast_speech *speech, const char *name, const char *value)
+{
+	struct speech_param param = {
+		.name = name,
+		.value = value,
+	};
+
+	if (!name) {
+		return -1;
+	}
+
+	/* send_request handles json ref */
+	return speech_aeap_send_request(speech->data,
+		"set", ast_json_pack("{s:{s:s}}", "params", name, value), &param);
+}
+
+static int handle_response_set(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	struct ast_json_iter *iter;
+	struct speech_param *param = data;
+
+	iter = ast_json_object_iter(ast_json_object_get(
+		ast_aeap_message_data(message), "params"));
+	if (!iter) {
+		log_error(aeap, "no 'set' parameter(s) returned");
+		return -1;
+	}
+
+	return !strcmp(ast_json_object_iter_key(iter), param->name) &&
+		!strcmp(ast_json_string_get(ast_json_object_iter_value(iter)), param->value) ? 0 : -1;
+}
+
+struct speech_setting {
+	const char *param;
+	size_t len;
+	char *buf;
+};
+
+static int handle_setting(struct ast_aeap *aeap, struct ast_json_iter *iter,
+	struct speech_setting *setting)
+{
+	const char *value;
+
+	if (strcmp(ast_json_object_iter_key(iter), setting->param)) {
+		log_error(aeap, "Unable to 'get' speech setting for '%s'", setting->param);
+		return -1;
+	}
+
+	value = ast_json_string_get(ast_json_object_iter_value(iter));
+	if (!value) {
+		log_error(aeap, "No value for speech setting '%s'", setting->param);
+		return -1;
+	}
+
+	ast_copy_string(setting->buf, value, setting->len);
+	return 0;
+}
+
+static int handle_results(struct ast_aeap *aeap, struct ast_json_iter *iter,
+	struct ast_speech_result **speech_results)
+{
+	struct ast_speech_result *result = NULL;
+	struct ast_json *json_results;
+	struct ast_json *json_result;
+	size_t i;
+
+	json_results = ast_json_object_iter_value(iter);
+	if (!json_results || !speech_results) {
+		log_error(aeap, "Unable to 'get' speech results");
+		return -1;
+	}
+
+	*speech_results = NULL;
+
+	for (i = 0; i < ast_json_array_size(json_results); ++i) {
+		if (!(result = ast_calloc(1, sizeof(*result)))) {
+			continue;
+		}
+
+		json_result = ast_json_array_get(json_results, i);
+
+		result->text = ast_strdup(ast_json_object_string_get(json_result, "text"));
+		result->score = ast_json_object_integer_get(json_result, "score");
+		result->grammar = ast_strdup(ast_json_object_string_get(json_result, "grammar"));
+		result->nbest_num = ast_json_object_integer_get(json_result, "best");
+		if (*speech_results) {
+			AST_LIST_NEXT(result, list) = *speech_results;
+			*speech_results = result;
+		} else {
+			*speech_results = result;
+		}
+	}
+
+	return 0;
+}
+
+/*!
+ * \internal
+ * \brief Handle a "get" response from an external application
+ *
+ * Basic structure of the expected JSON message to received:
+ *
+ *   {
+ *     response: "get"
+ *     "params" : { <name>: <value> | [ <results> ] }
+ *   }
+ *
+ * \param speech The speech engine
+ * \param param The name of the parameter to retrieve
+ * \param data User data passed to the response handler
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int handle_response_get(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	struct ast_json_iter *iter;
+
+	iter = ast_json_object_iter(ast_json_object_get(ast_aeap_message_data(message), "params"));
+	if (!iter) {
+		log_error(aeap, "no 'get' parameter returned");
+		return -1;
+	}
+
+	if (!strcmp(ast_json_object_iter_key(iter), "results")) {
+		return handle_results(aeap, iter, data);
+	}
+
+	return handle_setting(aeap, iter, data);
+}
+
+static int handle_response_setup(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	struct ast_format *format = data;
+	struct ast_json *json = ast_aeap_message_data(message);
+	const char *codec_name;
+
+	if (!json) {
+		log_error(aeap, "no 'setup' object returned");
+		return -1;
+	}
+
+	json = ast_json_object_get(json, "codecs");
+	if (!json || ast_json_array_size(json) == 0) {
+		log_error(aeap, "no 'setup' codecs returned");
+		return -1;
+	}
+
+	codec_name = ast_json_object_string_get(ast_json_array_get(json, 0), "name");
+	if (!codec_name || strcmp(codec_name, ast_format_get_codec_name(format))) {
+		log_error(aeap, "setup  codec '%s' unsupported", ast_format_get_codec_name(format));
+		return -1;
+	}
+
+	return 0;
+}
+
+static const struct ast_aeap_message_handler response_handlers[] = {
+	{ "setup", handle_response_setup },
+	{ "get", handle_response_get },
+	{ "set", handle_response_set },
+};
+
+static struct ast_aeap_params speech_aeap_params = {
+	.response_handlers = response_handlers,
+	.response_handlers_size = ARRAY_LEN(response_handlers),
+};
+
+/*!
+ * \internal
+ * \brief Create, and connect to an external application and send initial setup
+ *
+ * Basic structure of the JSON message to send:
+ *
+ *   {
+ *     "request": "setup"
+ *     "codecs": [
+ *         {
+ *             "name": <name>,
+ *             "attributes": { <name>: <value>, ..., }
+ *         },
+ *         ...,
+ *     ],
+ *     "params": { <name>: <value>, ..., }
+ *   }
+ *
+ * \param speech The speech engine
+ * \param format The format codec to use
+ *
+ * \returns 0 on success, -1 on error
+ */
+static int speech_aeap_engine_create(struct ast_speech *speech, struct ast_format *format)
+{
+	struct ast_aeap *aeap;
+	struct ast_variable *vars;
+	struct ast_json *json;
+
+	aeap = ast_aeap_create_and_connect_by_id(speech->engine->name, &speech_aeap_params);
+	if (!aeap) {
+		return -1;
+	}
+
+	speech->data = aeap;
+
+	/* Don't allow unloading of this module while an external application is in use */
+	ast_module_ref(ast_module_info->self);
+
+	vars = ast_aeap_attributes_get(speech->engine->name);
+
+	/* While the protocol allows sending of codec attributes, for now don't */
+	json = ast_json_pack("{s:[{s:s}],s:o*}", "codecs", "name", ast_format_get_codec_name(format),
+		"params", vars ? ast_json_object_create_vars(vars, "type,url,protocol,codecs") : NULL);
+
+	ast_variables_destroy(vars);
+
+	/* send_request handles json ref */
+	if (speech_aeap_send_request(speech->data, "setup", json, format)) {
+		ast_module_unref(ast_module_info->self);
+		return -1;
+	}
+
+	return 0;
+}
+
+static int speech_aeap_engine_destroy(struct ast_speech *speech)
+{
+	ao2_cleanup(speech->data);
+
+	ast_module_unref(ast_module_info->self);
+
+	return 0;
+}
+
+static int speech_aeap_engine_write(struct ast_speech *speech, void *data, int len)
+{
+	return ast_aeap_send_binary(speech->data, data, len);
+}
+
+static int speech_aeap_engine_dtmf(struct ast_speech *speech, const char *dtmf)
+{
+	return speech_aeap_set(speech, "dtmf", dtmf);
+}
+
+static int speech_aeap_engine_start(struct ast_speech *speech)
+{
+	ast_speech_change_state(speech, AST_SPEECH_STATE_READY);
+
+	return 0;
+}
+
+static int speech_aeap_engine_change(struct ast_speech *speech, const char *name, const char *value)
+{
+	return speech_aeap_set(speech, name, value);
+}
+
+static int speech_aeap_engine_get_setting(struct ast_speech *speech, const char *name,
+	char *buf, size_t len)
+{
+	struct speech_setting setting = {
+		.param = name,
+		.len = len,
+		.buf = buf,
+	};
+
+	return speech_aeap_get(speech, name, &setting);
+}
+
+static int speech_aeap_engine_change_results_type(struct ast_speech *speech,
+	enum ast_speech_results_type results_type)
+{
+	return speech_aeap_set(speech, "results_type",
+		ast_speech_results_type_to_string(results_type));
+}
+
+static struct ast_speech_result *speech_aeap_engine_get(struct ast_speech *speech)
+{
+	struct ast_speech_result *results = NULL;
+
+	if (speech_aeap_get(speech, "results", &results)) {
+		return NULL;
+	}
+
+	return results;
+}
+
+static void speech_engine_destroy(struct ast_speech_engine *engine)
+{
+	if (engine) {
+		ast_free(engine->name);
+		ast_free(engine);
+	}
+}
+
+static struct ast_speech_engine *speech_engine_create(const char *name)
+{
+	struct ast_speech_engine *engine;
+
+	engine = ast_calloc(1, sizeof(*engine));
+	if (!engine) {
+		ast_log(LOG_ERROR, "AEAP speech: unable create engine '%s'\n", name);
+		return NULL;
+	}
+
+	engine->name = ast_strdup(name);
+	if (!engine->name) {
+		ast_free(engine);
+		return NULL;
+	}
+
+	engine->create = speech_aeap_engine_create;
+	engine->destroy = speech_aeap_engine_destroy;
+	engine->write = speech_aeap_engine_write;
+	engine->dtmf = speech_aeap_engine_dtmf;
+	engine->start = speech_aeap_engine_start;
+	engine->change = speech_aeap_engine_change;
+	engine->get_setting = speech_aeap_engine_get_setting;
+	engine->change_results_type = speech_aeap_engine_change_results_type;
+	engine->get = speech_aeap_engine_get;
+
+	return engine;
+}
+
+static void speech_observer_created(const void *obj)
+{
+	struct ast_speech_engine *engine;
+
+	if (!ast_aeap_client_config_has_protocol(obj, "speech")) {
+		return;
+	}
+
+	engine = speech_engine_create(ast_sorcery_object_get_id(obj));
+
+	if (ast_speech_register(engine)) {
+		ast_free(engine);
+	}
+}
+
+static void speech_observer_deleted(const void *obj)
+{
+	if (!ast_aeap_client_config_has_protocol(obj, "speech")) {
+		return;
+	}
+
+	speech_engine_destroy(ast_speech_unregister2(ast_sorcery_object_get_id(obj)));
+}
+
+/*! \brief Observer for speech */
+static const struct ast_sorcery_observer speech_observer = {
+	.created = speech_observer_created,
+	.deleted = speech_observer_deleted,
+};
+
+#ifdef TEST_FRAMEWORK
+
+#include "asterisk/test.h"
+
+static enum ast_test_result_state res_speech_aeap_test(struct ast_test_info *info,
+	enum ast_test_command cmd, struct ast_test *test);
+
+#endif
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(res_speech_aeap_test);
+
+	ast_aeap_client_config_observer_remove(&speech_observer);
+
+	return 0;
+}
+
+static int load_module(void)
+{
+	speech_aeap_params.msg_type = ast_aeap_message_type_json;
+
+	if (ast_aeap_client_config_observer_add(&speech_observer)) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+	AST_TEST_REGISTER(res_speech_aeap_test);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "Asterisk External Application Speech Engine",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.load_pri = AST_MODPRI_CHANNEL_DEPEND,
+	.requires = "res_speech,res_aeap",
+);
+
+#ifdef TEST_FRAMEWORK
+
+#include "asterisk/http_websocket.h"
+#include "asterisk/file.h"
+
+static int speech_test_server_setup(struct ast_json *req, struct ast_json *resp)
+{
+	struct ast_json *params;
+
+	if (ast_json_object_set(resp, "codecs", ast_json_ref(ast_json_object_get(req, "codecs")))) {
+		return -1;
+	}
+
+	params = ast_json_object_get(req, "params"); /* Optional */
+	if (params && ast_json_object_set(resp, "params", ast_json_ref(params))) {
+		return -1;
+	}
+
+	return 0;
+}
+
+#define TEST_SPEECH_RESULTS_TEXT "foo"
+#define TEST_SPEECH_RESULTS_SCORE 7
+#define TEST_SPEECH_RESULTS_GRAMMAR "bar"
+#define TEST_SPEECH_RESULTS_BEST 1
+
+static int speech_test_server_get(struct ast_json *req, struct ast_json *resp)
+{
+	const char *param;
+	struct ast_json *json = NULL;
+
+	param = ast_json_string_get(ast_json_array_get(ast_json_object_get(req, "params"), 0));
+	if (!param) {
+		return -1;
+	}
+
+	if (!strcmp(param, "results")) {
+		json = ast_json_pack("{s:[{s:s,s:i,s:s,s:i}]}",
+			param,
+			"text", TEST_SPEECH_RESULTS_TEXT,
+			"score", TEST_SPEECH_RESULTS_SCORE,
+			"grammar", TEST_SPEECH_RESULTS_GRAMMAR,
+			"best", TEST_SPEECH_RESULTS_BEST);
+	} else {
+		/* Assume setting */
+		json = ast_json_pack("{s:s}", param, "bar");
+	}
+
+	if (!json || ast_json_object_set(resp, "params", json)) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static int speech_test_server_set(struct ast_json *req, struct ast_json *resp)
+{
+	if (ast_json_object_set(resp, "params", ast_json_ref(ast_json_object_get(req, "params")))) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static int speech_test_server_handle_request(struct ast_websocket *ws, const void *buf, uint64_t size)
+{
+	struct ast_json *req;
+	struct ast_json *resp;
+	const char *name;
+	char *resp_buf;
+	int res = 0;
+
+	req = ast_json_load_buf(buf, size, NULL);
+	if (!req) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to load json\n");
+		return -1;
+	}
+
+	name = ast_json_object_string_get(req, "request");
+	if (!name) {
+		ast_log(LOG_ERROR, "speech test handle request: no name\n");
+		ast_json_unref(req);
+		return -1;
+	}
+
+	resp = ast_json_pack("{s:s, s:s}", "response", name,
+		"id", ast_json_object_string_get(req, "id"));
+	if (!resp) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to create response '%s'\n", name);
+		ast_json_unref(req);
+		return -1;
+	}
+
+	if (!strcmp(name, "setup")) {
+		res = speech_test_server_setup(req, resp);
+	} else if (!strcmp(name, "get")) {
+		res = speech_test_server_get(req, resp);
+	} else if (!strcmp(name, "set")) {
+		res = speech_test_server_set(req, resp);
+	} else {
+		ast_log(LOG_ERROR, "speech test handle request: unsupported request '%s'\n", name);
+		return -1;
+	}
+
+	if (res) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to build response '%s'\n", name);
+		ast_json_unref(resp);
+		ast_json_unref(req);
+		return -1;
+	}
+
+	resp_buf = ast_json_dump_string(resp);
+	ast_json_unref(resp);
+
+	if (!resp_buf) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to dump response '%s'\n", name);
+		ast_json_unref(req);
+		return -1;
+	}
+
+	res = ast_websocket_write_string(ws, resp_buf);
+	if (res) {
+		ast_log(LOG_ERROR, "speech test handle request: unable to write response '%s'\n", name);
+	}
+
+	ast_json_unref(req);
+	ast_free(resp_buf);
+
+	return res;
+}
+
+static void speech_test_server_cb(struct ast_websocket *ws, struct ast_variable *parameters,
+	struct ast_variable *headers)
+{
+	int res;
+
+	if (ast_fd_set_flags(ast_websocket_fd(ws), O_NONBLOCK)) {
+		ast_websocket_unref(ws);
+		return;
+	}
+
+	while ((res = ast_websocket_wait_for_input(ws, -1)) > 0) {
+		char *payload;
+		uint64_t payload_len;
+		enum ast_websocket_opcode opcode;
+		int fragmented;
+
+		if (ast_websocket_read(ws, &payload, &payload_len, &opcode, &fragmented)) {
+			ast_log(LOG_ERROR, "speech test: Read failure in server loop\n");
+			break;
+		}
+
+		switch (opcode) {
+			case AST_WEBSOCKET_OPCODE_CLOSE:
+				ast_websocket_unref(ws);
+				return;
+			case AST_WEBSOCKET_OPCODE_BINARY:
+				ast_websocket_write(ws, opcode, payload, payload_len);
+				break;
+			case AST_WEBSOCKET_OPCODE_TEXT:
+				if (speech_test_server_handle_request(ws, payload, payload_len)) {
+					ast_websocket_unref(ws);
+					return;
+				}
+				break;
+			default:
+				break;
+		}
+	}
+	ast_websocket_unref(ws);
+}
+
+AST_TEST_DEFINE(res_speech_aeap_test)
+{
+	RAII_VAR(struct ast_codec *, codec, NULL, ao2_cleanup);
+	RAII_VAR(struct ast_format *, format, NULL, ao2_cleanup);
+	RAII_VAR(struct ast_speech_result *, results, NULL, ast_speech_results_free);
+	struct ast_speech speech;
+	enum ast_test_result_state res = AST_TEST_PASS;
+	char buf[8] = "";
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = "/res/aeap/speech/";
+		info->summary = "test the speech AEAP interface";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	memset(&speech, 0, sizeof(speech));
+
+	ast_test_validate(test, !ast_websocket_add_protocol("_aeap_test_speech_", speech_test_server_cb));
+	ast_test_validate(test, (codec = ast_codec_get("ulaw", AST_MEDIA_TYPE_AUDIO, 8000)));
+	ast_test_validate(test, (format = ast_format_create(codec)));
+
+	ast_test_validate(test, (speech.engine = speech_engine_create("_aeap_test_speech_")));
+
+	ast_test_validate_cleanup(test, !speech_aeap_engine_create(&speech, format), res, cleanup);
+	ast_test_validate_cleanup(test, !speech_aeap_engine_start(&speech), res, cleanup);
+	ast_test_validate_cleanup(test, !speech_aeap_engine_dtmf(&speech, "1"), res, cleanup);
+	ast_test_validate_cleanup(test, !speech_aeap_engine_change(&speech, "foo", "bar"), res, cleanup);
+	ast_test_validate_cleanup(test, !speech_aeap_engine_change_results_type(
+		&speech, AST_SPEECH_RESULTS_TYPE_NBEST), res, cleanup);
+
+	ast_test_validate_cleanup(test, !speech_aeap_engine_get_setting(
+		&speech, "foo", buf, sizeof(buf)), res, cleanup);
+	ast_test_validate_cleanup(test, !strcmp(buf, "bar"), res, cleanup);
+
+	ast_test_validate_cleanup(test, (results = speech_aeap_engine_get(&speech)), res, cleanup);
+	ast_test_validate_cleanup(test, !strcmp(results->text, TEST_SPEECH_RESULTS_TEXT), res, cleanup);
+	ast_test_validate_cleanup(test, results->score == TEST_SPEECH_RESULTS_SCORE, res, cleanup);
+	ast_test_validate_cleanup(test, !strcmp(results->grammar, TEST_SPEECH_RESULTS_GRAMMAR), res, cleanup);
+	ast_test_validate_cleanup(test, results->nbest_num == TEST_SPEECH_RESULTS_BEST, res, cleanup);
+
+cleanup:
+	speech_aeap_engine_destroy(&speech);
+	speech_engine_destroy(speech.engine);
+	ast_websocket_remove_protocol("_aeap_test_speech_", speech_test_server_cb);
+	return res;
+}
+
+#endif
diff --git a/tests/test_aeap.c b/tests/test_aeap.c
new file mode 100644
index 0000000..248275c
--- /dev/null
+++ b/tests/test_aeap.c
@@ -0,0 +1,242 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*** MODULEINFO
+	<depend>TEST_FRAMEWORK</depend>
+	<depend>res_aeap</depend>
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/test.h"
+#include "asterisk/module.h"
+#include "asterisk/file.h"
+#include "asterisk/http_websocket.h"
+#include "asterisk/json.h"
+
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#define CATEGORY "/res/aeap/"
+
+#define AEAP_TRANSPORT_TYPE "ws"
+#define AEAP_REMOTE_URL "ws://127.0.0.1:8088/ws"
+#define AEAP_REMOTE_PROTOCOL "echo"
+#define AEAP_MESSAGE_ID "foo"
+
+AST_TEST_DEFINE(create_and_connect)
+{
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating and connecting to an AEAP application";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		NULL, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL)));
+
+	return AST_TEST_PASS;
+}
+
+static void handle_string(struct ast_aeap *aeap, const char *buf, intmax_t size)
+{
+	int *passed = ast_aeap_user_data_object_by_id(aeap, AEAP_MESSAGE_ID);
+
+	if (strstr(buf, AEAP_MESSAGE_ID)) {
+		++*passed;
+	}
+}
+
+static void handle_timeout(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	int *passed = ast_aeap_user_data_object_by_id(aeap, AEAP_MESSAGE_ID);
+
+	++*passed;
+}
+
+AST_TEST_DEFINE(send_msg_handle_string)
+{
+	int passed = 0;
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+	struct ast_aeap_tsx_params tsx_params = {0};
+	struct ast_aeap_params aeap_params = {
+		.on_string = handle_string,
+	};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test an AEAP application string handler";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	tsx_params.timeout = 2000; /* Test will end by timing out */
+	tsx_params.on_timeout = handle_timeout;
+	tsx_params.wait = 1;
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		&aeap_params, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL)));
+
+	ast_test_validate(test, (!ast_aeap_user_data_register(aeap, AEAP_MESSAGE_ID, &passed, NULL)));
+	ast_test_validate(test, (tsx_params.msg = ast_aeap_message_create_request(
+		ast_aeap_message_type_json, "foo", AEAP_MESSAGE_ID, NULL, NULL)));
+	ast_test_validate(test, ast_aeap_send_msg_tsx(aeap, &tsx_params)); /* Returns fail on timeout */
+	ast_aeap_user_data_unregister(aeap, AEAP_MESSAGE_ID);
+
+	return passed == 2 ? AST_TEST_PASS : AST_TEST_FAIL;
+}
+
+static int handle_msg(struct ast_aeap *aeap, struct ast_aeap_message *message, void *data)
+{
+	int *passed = ast_aeap_user_data_object_by_id(aeap, AEAP_MESSAGE_ID);
+
+	*passed = !strcmp(ast_aeap_message_id(message), AEAP_MESSAGE_ID) &&
+		ast_aeap_message_is_named(message, data);
+
+	if (!*passed) {
+		ast_log(LOG_ERROR, "Name '%s' did not equal '%s' for message '%s'",
+			ast_aeap_message_name(message), (char *)data, ast_aeap_message_id(message));
+	}
+
+	return 0;
+}
+
+static const struct ast_aeap_message_handler handlers[] = {
+	{ "foo", handle_msg },
+};
+
+AST_TEST_DEFINE(send_msg_handle_response)
+{
+	int passed = 0;
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+	char *name = "foo";
+	struct ast_aeap_params aeap_params = {
+		.response_handlers = handlers,
+		.response_handlers_size = ARRAY_LEN(handlers),
+	};
+	struct ast_aeap_tsx_params tsx_params = {0};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test an AEAP application response handler";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	aeap_params.msg_type = ast_aeap_message_type_json;
+
+	tsx_params.timeout = 2000;
+	tsx_params.wait = 1;
+	tsx_params.obj = name;
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		&aeap_params, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL)));
+	ast_test_validate(test, (!ast_aeap_user_data_register(aeap, AEAP_MESSAGE_ID, &passed, NULL)));
+	ast_test_validate(test, (tsx_params.msg = ast_aeap_message_json_create_response(
+		name, AEAP_MESSAGE_ID, NULL, NULL)));
+	ast_test_validate(test, !ast_aeap_send_msg_tsx(aeap, &tsx_params));
+	ast_aeap_user_data_unregister(aeap, AEAP_MESSAGE_ID);
+
+	return passed ? AST_TEST_PASS : AST_TEST_FAIL;
+}
+
+AST_TEST_DEFINE(send_msg_handle_request)
+{
+	int passed = 0;
+	RAII_VAR(struct ast_aeap *, aeap, NULL, ao2_cleanup);
+	char *name = "foo";
+	struct ast_aeap_params aeap_params = {
+		.request_handlers = handlers,
+		.request_handlers_size = ARRAY_LEN(handlers),
+	};
+	struct ast_aeap_tsx_params tsx_params = {0};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test an AEAP application request handler";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	aeap_params.msg_type = ast_aeap_message_type_json;
+
+	tsx_params.timeout = 2000;
+	tsx_params.wait = 1;
+	tsx_params.obj = name;
+
+	ast_test_validate(test, (aeap = ast_aeap_create_and_connect(AEAP_TRANSPORT_TYPE,
+		&aeap_params, AEAP_REMOTE_URL, AEAP_REMOTE_PROTOCOL)));
+	ast_test_validate(test, (!ast_aeap_user_data_register(aeap, AEAP_MESSAGE_ID, &passed, NULL)));
+	ast_test_validate(test, (tsx_params.msg = ast_aeap_message_json_create_request(
+		name, AEAP_MESSAGE_ID, NULL, NULL)));
+	ast_test_validate(test, !ast_aeap_send_msg_tsx(aeap, &tsx_params));
+	ast_aeap_user_data_unregister(aeap, AEAP_MESSAGE_ID);
+
+	return passed ? AST_TEST_PASS : AST_TEST_FAIL;
+}
+
+static int load_module(void)
+{
+	AST_TEST_REGISTER(create_and_connect);
+	AST_TEST_REGISTER(send_msg_handle_string);
+	AST_TEST_REGISTER(send_msg_handle_response);
+	AST_TEST_REGISTER(send_msg_handle_request);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(send_msg_handle_request);
+	AST_TEST_UNREGISTER(send_msg_handle_response);
+	AST_TEST_UNREGISTER(send_msg_handle_string);
+	AST_TEST_UNREGISTER(create_and_connect);
+
+	return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Asterisk External Application Protocol Object Tests",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.requires = "res_aeap",
+);
diff --git a/tests/test_aeap_transaction.c b/tests/test_aeap_transaction.c
new file mode 100644
index 0000000..f1879ad
--- /dev/null
+++ b/tests/test_aeap_transaction.c
@@ -0,0 +1,179 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*** MODULEINFO
+	<depend>TEST_FRAMEWORK</depend>
+	<depend>res_aeap</depend>
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include <pthread.h>
+
+#include "asterisk/lock.h"
+#include "asterisk/test.h"
+#include "asterisk/module.h"
+#include "asterisk/res_aeap.h"
+#include "asterisk/res_aeap_message.h"
+
+#include "../res/res_aeap/general.h"
+#include "../res/res_aeap/transaction.h"
+
+#define CATEGORY "/res/aeap/transaction/"
+
+#define AEAP_TRANSACTION_ID "foo"
+
+static void handle_timeout(struct ast_aeap *aeap, struct ast_aeap_message *msg, void *obj)
+{
+	int *passed = obj;
+
+	++*passed;
+}
+
+static void *end_transaction(void *data)
+{
+	/* Delay a second before ending transaction */
+	struct timespec delay = { 1, 0 };
+	int *passed = aeap_transaction_user_obj(data);
+
+	while (nanosleep(&delay, &delay));
+
+	++*passed;
+	aeap_transaction_end(data, 0);
+
+	return NULL;
+}
+
+static enum ast_test_result_state exec(struct ast_test *test,
+	struct ast_aeap_tsx_params *params)
+{
+	pthread_t thread_id = AST_PTHREADT_NULL;
+	struct ao2_container *tsxs = NULL;
+	struct aeap_transaction *tsx = NULL;
+	enum ast_test_result_state res = AST_TEST_FAIL;
+	int passed = 0;
+
+	tsxs = aeap_transactions_create();
+	if (!tsxs) {
+		ast_test_status_update(test, "Failed to create transactions object\n");
+		goto exec_cleanup;
+	}
+
+	params->wait = 1;
+	params->obj = &passed;
+
+	tsx = aeap_transaction_create_and_add(tsxs, AEAP_TRANSACTION_ID, params, NULL);
+	if (!tsx) {
+		ast_test_status_update(test, "Failed to create transaction object\n");
+		goto exec_cleanup;
+	}
+
+	if (ast_pthread_create(&thread_id, NULL, end_transaction, ao2_bump(tsx))) {
+		ast_test_status_update(test, "Failed to create response thread\n");
+		ao2_ref(tsx, -1);
+		goto exec_cleanup;
+	}
+
+	if (aeap_transaction_start(tsx)) {
+		ast_test_status_update(test, "Failed to start transaction request\n");
+		goto exec_cleanup;
+	}
+
+	if (passed == 1) {
+		res = AST_TEST_PASS;
+	}
+
+exec_cleanup:
+
+	if (thread_id != AST_PTHREADT_NULL) {
+		pthread_cancel(thread_id);
+		pthread_join(thread_id, NULL);
+	}
+
+	aeap_transaction_end(tsx, 0);
+	ao2_cleanup(tsxs);
+
+	return res;
+}
+
+AST_TEST_DEFINE(transaction_exec)
+{
+	struct ast_aeap_tsx_params params = {
+		.timeout = 5000, /* Give plenty of time for test thread to end */
+	};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating a basic AEAP transaction request";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	return exec(test, &params);
+}
+
+AST_TEST_DEFINE(transaction_exec_timeout)
+{
+	struct ast_aeap_tsx_params params = {
+		.timeout = 100, /* Ensure timeout occurs before test thread ends */
+		.on_timeout = handle_timeout,
+	};
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating a AEAP transaction request that times out";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	return exec(test, &params);
+}
+
+static int load_module(void)
+{
+	AST_TEST_REGISTER(transaction_exec);
+	AST_TEST_REGISTER(transaction_exec_timeout);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(transaction_exec_timeout);
+	AST_TEST_UNREGISTER(transaction_exec);
+
+	return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Asterisk External Application Protocol Transaction Tests",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.requires = "res_aeap",
+);
diff --git a/tests/test_aeap_transport.c b/tests/test_aeap_transport.c
new file mode 100644
index 0000000..4691499
--- /dev/null
+++ b/tests/test_aeap_transport.c
@@ -0,0 +1,241 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2021, Sangoma Technologies Corporation
+ *
+ * Kevin Harwell <kharwell at sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*** MODULEINFO
+	<depend>TEST_FRAMEWORK</depend>
+	<depend>res_aeap</depend>
+	<support_level>core</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/sem.h"
+#include "asterisk/test.h"
+#include "asterisk/module.h"
+
+#include "../res/res_aeap/transport.h"
+
+#define CATEGORY "/res/aeap/transport/"
+
+#define TRANSPORT_URL "ws://127.0.0.1:8088/ws"
+#define TRANSPORT_URL_INVALID "ws://127.0.0.1:8088/invalid"
+#define TRANSPORT_PROTOCOL "echo"
+#define TRANSPORT_PROTOCOL_INVALID "invalid"
+
+AST_TEST_DEFINE(transport_create_invalid)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating an AEAP invalid transport type";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Transport is expected to be NULL here */
+	ast_test_validate(test, !(transport = aeap_transport_create("invalid")));
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_create)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test creating an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Type is based off the scheme, so just pass in the URL here */
+	ast_test_validate(test, (transport = aeap_transport_create(TRANSPORT_URL)));
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_connect)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test connecting to an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Type is based off the scheme, so just pass in the URL for the type */
+	ast_test_validate(test, (transport = aeap_transport_create_and_connect(
+		TRANSPORT_URL, TRANSPORT_URL, TRANSPORT_PROTOCOL)));
+
+	ast_test_validate(test, aeap_transport_is_connected(transport));
+	ast_test_validate(test, !aeap_transport_disconnect(transport));
+	ast_test_validate(test, !aeap_transport_is_connected(transport));
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_connect_fail)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test connecting failure for an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* Test invalid address */
+	ast_test_validate(test, (transport = aeap_transport_create(TRANSPORT_URL)));
+
+	ast_test_validate(test, aeap_transport_connect(transport,
+		TRANSPORT_URL_INVALID, TRANSPORT_PROTOCOL));
+
+	ast_test_validate(test, !aeap_transport_is_connected(transport));
+
+	aeap_transport_destroy(transport);
+
+	/* Test invalid protocol */
+	ast_test_validate(test, (transport = aeap_transport_create(TRANSPORT_URL)));
+
+	ast_test_validate(test, aeap_transport_connect(transport,
+		TRANSPORT_URL, TRANSPORT_PROTOCOL_INVALID));
+
+	ast_test_validate(test, !aeap_transport_is_connected(transport));
+
+	sleep(1); /* Give time for underlying transport to fully disconnect */
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_binary)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+	int num = 38;
+	enum AST_AEAP_DATA_TYPE rtype;
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test binary I/O from an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	ast_test_validate(test, (transport = aeap_transport_create_and_connect(
+		TRANSPORT_URL, TRANSPORT_URL, TRANSPORT_PROTOCOL)));
+
+	ast_test_validate(test, aeap_transport_write(transport, &num, sizeof(num),
+		AST_AEAP_DATA_TYPE_BINARY) == sizeof(num));
+	ast_test_validate(test, aeap_transport_read(transport, &num,
+		sizeof(num), &rtype) == sizeof(num));
+	ast_test_validate(test, rtype == AST_AEAP_DATA_TYPE_BINARY);
+	ast_test_validate(test, num == 38);
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(transport_string)
+{
+	RAII_VAR(struct aeap_transport *, transport, NULL, aeap_transport_destroy);
+	char buf[16];
+	enum AST_AEAP_DATA_TYPE rtype;
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = __func__;
+		info->explicit_only = 0;
+		info->category = CATEGORY;
+		info->summary = "test string I/O from an AEAP transport";
+		info->description = info->summary;
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	ast_test_validate(test, (transport = aeap_transport_create_and_connect(
+		TRANSPORT_URL, TRANSPORT_URL, TRANSPORT_PROTOCOL)));
+
+	ast_test_validate(test, aeap_transport_write(transport, "foo bar baz", 11,
+		AST_AEAP_DATA_TYPE_STRING) == 11);
+	ast_test_validate(test, aeap_transport_read(transport, buf,
+		sizeof(buf) / sizeof(char), &rtype) == 11);
+	ast_test_validate(test, rtype == AST_AEAP_DATA_TYPE_STRING);
+	ast_test_validate(test, !strcmp(buf, "foo bar baz"));
+
+	return AST_TEST_PASS;
+}
+
+static int load_module(void)
+{
+	AST_TEST_REGISTER(transport_create_invalid);
+	AST_TEST_REGISTER(transport_create);
+	AST_TEST_REGISTER(transport_connect);
+	AST_TEST_REGISTER(transport_connect_fail);
+	AST_TEST_REGISTER(transport_binary);
+	AST_TEST_REGISTER(transport_string);
+
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+static int unload_module(void)
+{
+	AST_TEST_UNREGISTER(transport_string);
+	AST_TEST_UNREGISTER(transport_binary);
+	AST_TEST_UNREGISTER(transport_connect_fail);
+	AST_TEST_UNREGISTER(transport_connect);
+	AST_TEST_UNREGISTER(transport_create);
+	AST_TEST_UNREGISTER(transport_create_invalid);
+
+	return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Asterisk External Application Protocol Transport Tests",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.requires = "res_aeap",
+);

-- 
To view, visit https://gerrit.asterisk.org/c/asterisk/+/17330
To unsubscribe, or for help writing mail filters, visit https://gerrit.asterisk.org/settings

Gerrit-Project: asterisk
Gerrit-Branch: master
Gerrit-Change-Id: Iaa4b259f84aa63501e5fd2a6fb107f900b4d4ed2
Gerrit-Change-Number: 17330
Gerrit-PatchSet: 1
Gerrit-Owner: Kevin Harwell <kharwell at digium.com>
Gerrit-MessageType: newchange
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.digium.com/pipermail/asterisk-code-review/attachments/20211108/dab821c7/attachment-0001.html>


More information about the asterisk-code-review mailing list