Extending Euphonium~
Euphonium Architecture~
There are essentially 4 layers of the Euphonium application which are:
- The Web UI written in React / Javascript
- The "Application layer" written in Berry Scripting language.
- A platform agnostic "Feature layer", written in C/C++
- A platform Specific "Driver layer", written in C/C++
Plugins can integrate into each layer, and communicate between each layer. Having a firm understanding of how these layers communicate will help you understand what plugin's can do and what code you need to write when creating a new plugin.
Web Plugins in Brief~
Plugins can expose functionality the users in the Web Application via the
make_form()
method of the application layer plugin. Forms in the web app have
two distinct functions: (1) exposing the current plugin state to the user, and
(2) receiving inputs such as updated settings from users. The Web app is
extended by creating an application layer plugin and creating a form using it's
make_form()
method.
Additionally the Web app is a React (Vite) app located in the web/
directory
that you can modify to your heart's content.
Application Layer Plugins in Brief~
Application layer plugins are written in Berry Scripting language and inherit from the Plugin Class
Plugin scripts located in the euphonium/scripts/plugins
and are used to define a
new plugin class, instantiate the plugin and register it with Euphonium like so:
class MyPlugin : Plugin
def init()
# Define constants like the plugin name
end
def make_form(cts,state)
# Create the form that allows users to set plugin settings in the Web UI
end
def on_event(event, data)
# Handle events, such as plugin initialization, updates to the plugin
# state (in response to user interaction with the Web app)
end
end
# Instantiate your plugin
my_plugin = MyPlugin()
# Register your plugin with the euphonium application
euphonium.register_plugin(my_plugin)
As of this writing, these are the events handled by the plugin's on_event()
method:
- EVENT_CONFIG_UPDATED
- EVENT_VOLUME_UPDATED
- EVENT_SYSTEM_INIT
- EVENT_SET_PAUSE
- EVENT_PLUGIN_INIT
Feature Layer Plugins in Brief~
Feature plugin interfaces are defined in the
euphonium/include/plugins/[pluginName]
directories and the methods are defined
(out of line) in the euphonium/src/plugins/[pluginName]
directories.
Feature plugins inherit from the Module and bell::Task classes.
For the purposes creating plugins,
bell::Task
has the interface:
class Task {
public:
std::string taskName;
protected:
// Override this to start your plugin task
virtual void runTask() = 0;
}
and the
Module
class has this interface:
class Module {
public:
Module() {} // constructor
// Module name:
std::string name;
// Module Status:
ModuleStatus status = ModuleStatus::SHUTDOWN;
// A shared pointer to the berry runtime (vm) which can be used to expose
// data and functions as to the berry runtime
std::shared_ptr<berry::VmState> berry;
// A shared pointer to the luaEventBus where events can be posted to the
// application from the plugin
std::shared_ptr<EventBus> luaEventBus;
// The audioBuffer
std::shared_ptr<MainAudioBuffer> audioBuffer;
// configuration managed by the
berry::map config;
// PLUGIN LIFE CYCLE METHODS:
// ??
virtual void loadScript(std::shared_ptr<ScriptLoader> scriptLoader) = 0;
// Called by the Core application when the Berry runtime is ready to have
// data and functions exported
virtual void setupBindings() = 0;
// ??
virtual void startAudioThread() = 0;
// Called by the Core application at shut down
virtual void shutdown() = 0;
};
When Feature plugins are loaded, they are provided with a reference to the
berry
and luaEventBus
, and then their setupBindings()
method is called
with the following:
// euphonium/src/Core.cpp
plugin->berry = this->berry;
plugin->luaEventBus = this->luaEventBus;
plugin->setupBindings();
The berry
and luaEventsBus
are used to communicate with the application
layer, as illustrated below.
Driver Layer Plugins in Brief~
Driver layer plugins (drivers, really) implement platform specific features and
are located in the platform specific target/[platform]
directories. For
example, the I2C driver for the ESP32 is contained in the
target/esp32/app/main/driver
directory. Drivers are used to expose platform
specific features (e.g. I2C) to the application layer.
Communication between Euphonium Layers~
Communication from Web Forms to Application Layer Plugins~
The web forms in the settings section of the app communicate to the application
layer via HTTP requests made to the /plugins/:name
endpoint. When a POST
request is made to this endpoint, the request body is used to update the
plugin's state, and the plugin is notified that it's stat has been updated with
a call to it's on_event()
method like so:
plugin.on_event(EVENT_CONFIG_UPDATED, plugin.state)
Note that even though the new plugin stat is supplied as the second argument to this method call, the plugin's state has already been replaced before the method is called, so this invocation is merely an opportunity to respond to the state change; the method does not need to update it's own state in order for the state to be updated in this case.
Application Layer Plugin HTTP APIs~
Application layer plugins may expose API endpoints by registering a callback with the http plugin.
Examples~
Application Layer Plugin Example~
This plugin toggles a pin LOW or HIGH in response to http POST
requests made
at a custom endpoint. Users can select the output pin on the in the web ap
under the "LED Driver" settings.
class LED_Driver : Plugin
var pin
def init()
self.apply_default_values()
self.name = "led_driver"
self.theme_color = "#d2c464"
self.display_name = "LED Driver"
self.type = "plugin"
end
def make_form(cts,state)
# Create the form that allows users select the output pin for the LED
ctx.create_group('led-driver', { 'label': 'General' })
ctx.number_field('pin', {
'label': "Output Pin",
'default': "0",
'group': 'led-driver',
})
end
def on_event(event, data)
if event == EVENT_CONFIG_UPDATED
if data.find('pin') != nil && data['pin'] != '0'
gpio.pin_mode(self.state['pin'], gpio.INPUT_PULLUP)
end
end
if event == EVENT_PLUGIN_INIT
if self.state.find('pin') != nil && self.state['pin'] != '0'
gpio.pin_mode(self.state['pin'], gpio.INPUT_PULLUP)
end
end
end
def set_pin(pin_state)
# custom method to be called by the HTTP callback
if self.state.find('pin') == nil || self.state.pin == '0'
# Output pin has not been set
return
end
gpio.digital_write(self.pin,pin_state)
end
end
# Instantiate the application layer plugin
led_driver = LED_Driver()
# Register the application layer plugin with the euphonium application
euphonium.register_plugin(CSpotPlugin())
# Register the http endpoint
http.handle("POST","/toggle-pin",def
if request.json_body() == 'true'
led_driver.set_pin(gpio.HIGH)
else
led_driver.set_pin(gpio.LOW)
end
end)
The Event Bus~
Communication within the Feature and to the Application layer can be achieved by posting messages to an Feature layer event bus. Messages posted to the Feature layer event bus are propagated to both Feature and application layer event subscribers.
When Feature plugins are registered, a reference to the mainEventBus
is
bound to the plugin's luaEventBus
property. Modules can therefor post events
to the event bus using
this->luaEventBus->postEvent(std::move(event));
Feature layer plugins can subscribe to the event bus by registering a
listener which implements the EventSubscriber interface. Plugins which implement
an appropriate handleEvent()
method can therefore register themselves as
subscribers using:
auto subscriber = dynamic_cast<EventSubscriber *>(this);
luaEventBus->addListener(EventType::LUA_MAIN_EVENT, *subscriber);
Notably the Euphonium Core registers itself as a subscriber, and uses that
subscription to propagate events to the handle_event
global in the application
layer which then propagate those events to registered event handlers and plugins.
Application layer plugins can receive events by registering a callback manually with the euphonium core using:
euphonium.register_handler('wifiStateChanged', def (event)
# Do something when the wifi state changes...
end)
Note that only one handler register may be registered to a given event.
Asynchronous tasks~
Application may need to do tasks periodically, such as update a display, call out to a web server, etc. As of this writing, these types of tasks cannot be accomplished in the application layer as berry language is synchronous, and code like this will block the application indefinitely :
# Do *not* to this:
while 1
do_something()
sleep_ms(1000)
end
Instead you'll need to create either a feature or driver plugin which inherits
from bell::Task
and runs your asynchronous tasks in a FreeRTOS task, like so:
MyAwesomePlugin::MyAwesomePlugin() : bell::Task("awesome", 6 * 1024, 0, 1) {
name = "awesome";
// create the FreeRTOS Task to run the `runTask()` method
this->startTask();
}
void MyAwesomePlugin::runTask() {
while (true) // or any FreeRTOS idiom like `for(auto item: someQueue)`
{
do_something()
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
Exposing C++ Objects in the Berry language~
While events can be propagated from the feature layer to the application layer, (as of this writing) the same mechanism cannot be used to communicate from the application layer to the feature layer. In order for the the feature layer to receive events from the application layer, feature plugins can bind functions, methods, and values into berry runtime.
This is accomplished using the convenience methods of the berry
reference
that is attached to each feature plugin, such as:
// bind the MQTTPlugin::publish method to mqqt.plugin function in the berry runtime
berry->export_this("publish", this, &MQTTPlugin::publish, "mqtt");
// bind the gpioDigitalWrite function to gpio.digital_write function in the berry runtime
berry->export_function("digital_write", &gpioDigitalWrite, "gpio");
// bind the sleepMS function to sleep_ms global function in the berry runtime
berry->export_function("sleep_ms", &sleepMS);
Installing Plugins~
Installing Application layer plugins~
The Berry scripts that define the application layer plugins are stored in the
euphonium/scripts/plugin
directory.
Pro Tip: Start by creating a blank file (my-plugin.be
) for your plugin,
then compile, flash, and run the Euphonium application. This will create a new
empty file in the that you can edit using the Web IDE. The Web IDE is a web
application that you can run from your local machine, connect to your ESP32, edit
your new plugin and debug your code in real time. When the plugin is working
the way you like it, copy your code out of the web IDE and into your plugin's
.be
file.
You can start the Web IDE by navigating to the /web-ide
in the repo, with
these commands:
# install the dependencies (only required once)
yarn install
# Run the Web IDE application
yarn start
then Navigate to http://localhost:3000
(if the window doesn't open on it's
own) to use the Web IDE
Installing Feature layer (C/C++) plugins~
Installing the .cpp
and .h
files:~
Your plugin will need .h
and .cpp
files, which you can create with:
pushd euphonium/src/plugins
mkdir newFeature
cd newFeature
touch newFeaturePlugin.cpp
popd
and:
pushd euphonium/include/plugins
mkdir newFeature
cd newFeature
touch newFeaturePlugin.h
popd
Next, you'll need to tell the build system about the source files for your new
plugin by adding to the list of glob patterns (e.g.
"src/plugins/my-plugin/*.cpp"
or "src/plugins/my-plugin/*.c"
)
here
in the same CMakeLists.txt
file.
You also need to add your include directory near the bottom of
euphonium/CMakeLists.txt
like so:
include_directories("include/plugins/my-plugin")
Finally, you'll need to add include your plugin's header file to
euphonium/include/Core.cpp
, and add your plugin to the list of registered
plugins
in euphonium/src/Core.cpp
Using libraries with Feature Plugins~
If your plugin is going to rely on existing external C/C++ libraries, then
you'll need to load them into the repo (preferably as sub-modules), and tell the
build system about the additional libraries. For example, if you want to library
from github.com/some/great-library
, then you'll want to clone in into euphonium/
with:
git submodule add ../../some/great-library euphonium/great-library
and then update euphonium/CMakeLists.txt
with your dependency. Probably
something like
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bell ${CMAKE_CURRENT_BINARY_DIR}/bell)
as well as adding your library to this lists in target_link_libraries()
and
target_include_directories()
. A note of caution: Libraries that are aware of
or depend on the esp-idf features are likely to flummox the build process
if they are included in the euphonium core (euphonium/
)
Installing Platform Specific Drivers~
Platform specific drivers, will depend on the platform. New feature drivers for the ESP32, for example, can be installed with:
pushd targets/esp32/app/main/driver
mkdir feature
cd feature
touch featureDriver.cpp
touch featureDriver.h
popd
Driver functionality should be exposed to the Application layer in the usual ways:
-
Exporting C/C++ functions into the Berry language vm with the export helpers (
berry->export_something()
), which the ESP32 platform exposes to the drivers registers here -
create a plugin which inherits from
bell::Task
and register it as a plugin in main.cpp, which is how the Bluetooth Plugin is registered in the ESP32 target.
Using ESP-IDF Libraries with (Drivers)~
When writing drivers for the ESP32, the ESP-IDF will automatically include
projects in the targets/esp32/app/components
directory. For example, if you
want to add a display using the awesome u8g2
library, you could add the
required libraries as git submodules like so:
git submodule add ../../olikraus/u8g2 targets/esp32/app/components/u8g2
git submodule add ../../mkfrey/u8g2-hal-esp-idf targets/esp32/app/components/u8g2-hal-esp-idf
and like that, your new components can be consumed in your driver files.