Plugin Development
Last Updated: January 2025 API Version: 1.0 Minimum HelixScreen Version: 2.0.0
Table of Contents
Section titled “Table of Contents”- Overview
- Quick Start
- Plugin Structure
- Plugin Lifecycle
- PluginAPI Reference
- UI Development
- Threading Model
- Common Patterns
- Building Plugins
- Debugging
- Best Practices and Gotchas
- Complete Examples
Overview
Section titled “Overview”The HelixScreen plugin system allows third-party developers to extend the 3D printer touchscreen interface with custom functionality. Plugins are dynamically loaded shared libraries that can:
- Monitor printer state - Subscribe to temperature, print status, and other real-time data
- Respond to events - React to print start/pause/complete, filament changes, etc.
- Inject UI widgets - Add custom widgets to designated areas of existing panels
- Register services - Expose functionality for other plugins to use
- Subscribe to Moonraker - Receive raw Klipper object updates
Prerequisites
Section titled “Prerequisites”- C++17 or later compiler
- LVGL 9.4 knowledge (for UI development)
- Familiarity with HelixScreen’s declarative XML UI system
- Understanding of the Klipper/Moonraker architecture (for printer integration)
Quick Start
Section titled “Quick Start”This section gets you from zero to a working plugin as quickly as possible.
Minimal “Hello World” Plugin
Section titled “Minimal “Hello World” Plugin”Directory structure:
plugins/ hello-world/ manifest.json hello_world.cpp Makefilemanifest.json:
{ "id": "hello-world", "name": "Hello World", "version": "1.0.0", "author": "Your Name", "description": "A minimal example plugin", "helix_version": ">=2.0.0"}hello_world.cpp:
#include "plugin_api.h"
using namespace helix::plugin;
// Store API pointer for use in callbacksstatic PluginAPI* g_api = nullptr;
// Required: Plugin initializationextern "C" bool helix_plugin_init(PluginAPI* api, const char* plugin_dir) { g_api = api;
api->log_info("Hello World plugin initialized!"); api->log_info("Plugin directory: " + std::string(plugin_dir));
// Subscribe to printer connection events api->on_event(events::PRINTER_CONNECTED, [](const EventData& event) { g_api->log_info("Printer connected!"); });
return true; // Return false to abort loading}
// Required: Plugin cleanupextern "C" void helix_plugin_deinit() { if (g_api) { g_api->log_info("Hello World plugin shutting down"); } g_api = nullptr;}
// Optional: API version for compatibility checkingextern "C" const char* helix_plugin_api_version() { return "1.0";}Makefile:
CXX = g++CXXFLAGS = -std=c++17 -fPIC -O2 -Wall -WextraLDFLAGS = -shared
# Adjust this path to your HelixScreen installationHELIX_INCLUDE = /path/to/helixscreen/include
PLUGIN_NAME = hello-worldSOURCES = hello_world.cppOUTPUT = libhelix_$(PLUGIN_NAME).so
ifeq ($(shell uname),Darwin) OUTPUT = libhelix_$(PLUGIN_NAME).dylibendif
all: $(OUTPUT)
$(OUTPUT): $(SOURCES) $(CXX) $(CXXFLAGS) -I$(HELIX_INCLUDE) $(LDFLAGS) -o $@ $^
clean: rm -f $(OUTPUT)
.PHONY: all cleanBuild and Test
Section titled “Build and Test”cd plugins/hello-worldmake
# Run HelixScreen with plugins enabled./helix-screen --test --plugins-dir ./plugins -vvYou should see in the logs:
[plugin:hello-world] Hello World plugin initialized![plugin:hello-world] Plugin directory: ./plugins/hello-worldPlugin Structure
Section titled “Plugin Structure”Directory Layout
Section titled “Directory Layout”Each plugin lives in its own directory under the plugins folder:
plugins/ my-plugin/ manifest.json # Required: Plugin metadata libhelix_my-plugin.so # Required: Compiled plugin library my_widget.xml # Optional: XML UI components assets/ # Optional: Images, fonts, etc. icon.png README.md # Optional: Documentationmanifest.json Schema
Section titled “manifest.json Schema”| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier (alphanumeric, hyphens, underscores) |
name | string | Yes | Human-readable display name |
version | string | Yes | Semantic version (e.g., “1.2.3”) |
helix_version | string | No | Required HelixScreen version (e.g., ”>=2.0.0”) |
author | string | No | Plugin author name |
description | string | No | Brief description |
entry_point | string | No | Init function name (default: “helix_plugin_init”) |
dependencies | array | No | Other plugin IDs this plugin requires |
ui | object | No | UI configuration (see below) |
UI Configuration:
{ "ui": { "settings_page": true, "navbar_panel": false, "injection_points": ["panel_widget_area", "print_status_extras"] }}Library Naming Conventions
Section titled “Library Naming Conventions”The plugin manager searches for libraries in this order:
Linux:
libhelix_<plugin_id>.solib<plugin_id>.so<plugin_id>.so- Any
.sofile in the directory
macOS:
libhelix_<plugin_id>.dyliblib<plugin_id>.dylib<plugin_id>.dylib- Any
.dylibfile in the directory
Plugin Lifecycle
Section titled “Plugin Lifecycle”Plugin States
Section titled “Plugin States”Plugins exist in one of four states:
| State | Description |
|---|---|
| Discovered | Plugin found in plugins directory with valid manifest |
| Disabled | Discovered but not in config’s enabled list - silently skipped |
| Enabled | In config’s enabled list, will attempt to load |
| Loaded | Successfully initialized and running |
| Failed | Enabled but failed to load (version mismatch, missing library, init error) |
Enabling and Disabling Plugins
Section titled “Enabling and Disabling Plugins”Plugins must be explicitly enabled in the configuration file. Simply placing a plugin in the plugins/ directory does not activate it.
Config file location: ~/.config/helix-screen/settings.json (or platform equivalent)
Enable a plugin:
{ "plugins": { "enabled": ["hello-world", "led-effects", "spoolman"] }}Disable a plugin:
Remove it from the enabled array, or use the UI:
- If a plugin fails to load, a toast notification appears with a [Disable] button
- Go to Settings > Plugins to see all plugins and disable failed ones
Why explicit enable?
- Security: prevents accidentally running unknown code
- Performance: only loads what you need
- Control: easily test plugins by toggling in config
Lifecycle Stages
Section titled “Lifecycle Stages”┌─────────────┐│ Discovery │ Plugin manager scans plugins directory for manifest.json files└──────┬──────┘ │ ▼┌─────────────┐│ Enable Check│ Is plugin ID in config's /plugins/enabled list?└──────┬──────┘ │ Yes │ No ▼ ▼┌─────────────┐ ┌─────────────┐│ Load │ │ (Skipped) │ Plugin stays discovered but inactive└──────┬──────┘ └─────────────┘ │ ▼┌─────────────┐│ Init │ helix_plugin_init() called with PluginAPI└──────┬──────┘ │ Success │ Failure ▼ ▼┌─────────────┐ ┌─────────────┐│ Running │ │ Failed │ Error logged, toast shown with [Disable]└──────┬──────┘ └─────────────┘ │ ▼┌─────────────┐│ Deinit │ helix_plugin_deinit() called for cleanup└──────┬──────┘ │ ▼┌─────────────┐│ Unload │ dlclose() unloads the library└─────────────┘Handling Load Failures
Section titled “Handling Load Failures”When an enabled plugin fails to load:
-
Toast notification appears:
- Single failure:
"plugin-id" failed to load [Disable] - Multiple failures:
N plugins failed to load [Manage]
- Single failure:
-
[Disable] button removes the plugin from config’s enabled list (no restart needed)
-
Settings > Plugins shows all plugins organized by state:
- ✅ Loaded - Running plugins
- ⚠️ Disabled - Discovered but not enabled
- ❌ Failed - Enabled but couldn’t load (with error details and disable button)
Entry Point Contract
Section titled “Entry Point Contract”Every plugin must export these C functions:
// Required: Called during plugin loading// Return true to complete loading, false to abortextern "C" bool helix_plugin_init(PluginAPI* api, const char* plugin_dir);
// Required: Called during plugin unloading// Clean up any resources allocated during init/runningextern "C" void helix_plugin_deinit();
// Optional: Return API version for compatibility checking// If provided, must match host's PLUGIN_API_VERSION ("1.0")extern "C" const char* helix_plugin_api_version();Initialization Order
Section titled “Initialization Order”Plugins are loaded in dependency order using topological sort:
- If plugin A depends on plugin B, B is loaded first
- Circular dependencies cause both plugins to fail loading
- Missing dependencies cause the dependent plugin to fail
Cleanup Guarantees
Section titled “Cleanup Guarantees”When helix_plugin_deinit() is called:
- All UI widgets injected by your plugin have already been removed
- Event subscriptions are automatically unsubscribed
- Registered services are automatically unregistered
- Moonraker subscriptions are automatically cleaned up
You should still clean up:
- Any allocated memory you own
- File handles, network connections
- Background threads you started
PluginAPI Reference
Section titled “PluginAPI Reference”The PluginAPI class is your plugin’s interface to HelixScreen. A pointer is passed to helix_plugin_init() and remains valid until helix_plugin_deinit() is called.
Core Services
Section titled “Core Services”moonraker_api()
Section titled “moonraker_api()”MoonrakerAPI* moonraker_api() const;Returns the high-level Moonraker API for common printer operations.
Nullability: May be nullptr if Moonraker is not connected. Always check before use.
if (auto* api = g_api->moonraker_api()) { api->send_gcode("G28"); // Home all axes}moonraker_client()
Section titled “moonraker_client()”MoonrakerClient* moonraker_client() const;Returns the low-level WebSocket client for raw Moonraker communication.
Nullability: May be nullptr if not connected.
printer_state()
Section titled “printer_state()”PrinterState& printer_state() const;Returns the reactive printer state object. Use this to read current values or access LVGL subjects for binding.
Nullability: Always valid (never null).
PrinterState& state = g_api->printer_state();float nozzle_temp = state.get_nozzle_temperature();config()
Section titled “config()”Config* config() const;Returns the configuration manager for reading/writing settings.
Nullability: May be nullptr during early initialization.
plugin_id()
Section titled “plugin_id()”const std::string& plugin_id() const;Returns this plugin’s ID (as declared in manifest.json).
Event System
Section titled “Event System”on_event()
Section titled “on_event()”EventSubscriptionId on_event(const std::string& event_name, EventCallback callback);Subscribe to an application event. Callbacks are invoked on the main thread.
Parameters:
event_name: One of theevents::*constantscallback: Function to call when event occurs
Returns: Subscription ID for later unsubscription.
auto sub_id = api->on_event(events::PRINT_STARTED, [](const EventData& event) { std::string filename = event.payload.value("filename", "unknown"); g_api->log_info("Print started: " + filename);});off_event()
Section titled “off_event()”bool off_event(EventSubscriptionId id);Unsubscribe from an event. Returns true if subscription was found and removed.
Note: Subscriptions are automatically cleaned up when your plugin unloads.
Event Reference Table
Section titled “Event Reference Table”| Event Name | Payload Fields | Description |
|---|---|---|
events::PRINTER_CONNECTED | (none) | Moonraker WebSocket connected |
events::PRINTER_DISCONNECTED | (none) | Moonraker WebSocket disconnected |
events::PRINT_STARTED | filename | Print job started |
events::PRINT_PAUSED | (none) | Print job paused |
events::PRINT_RESUMED | (none) | Print job resumed |
events::PRINT_COMPLETED | (none) | Print job completed successfully |
events::PRINT_CANCELLED | (none) | Print job cancelled by user |
events::PRINT_ERROR | error | Print job failed |
events::TEMPERATURE_UPDATED | heater, current, target | Heater temperature changed |
events::FILAMENT_LOADED | slot, material, color | Filament loaded into extruder |
events::FILAMENT_UNLOADED | slot | Filament unloaded |
events::KLIPPER_STATE_CHANGED | state | Klipper state (ready/shutdown/error/startup) |
events::THEME_CHANGED | theme | Light/dark theme changed |
events::NAVIGATION_CHANGED | panel | User navigated to different panel |
Moonraker Subscriptions
Section titled “Moonraker Subscriptions”subscribe_moonraker()
Section titled “subscribe_moonraker()”MoonrakerSubscriptionId subscribe_moonraker( const std::vector<std::string>& objects, MoonrakerCallback callback);Subscribe to Klipper object updates. Unlike direct MoonrakerClient subscriptions, this method:
- Queues subscriptions if Moonraker isn’t connected yet
- Automatically applies them when connection is established
- Cleans up automatically when plugin unloads
Parameters:
objects: Klipper objects to subscribe to (e.g.,{"extruder", "heater_bed"})callback: Function receiving JSON updates
Returns: Subscription ID (use with unsubscribe_moonraker()).
api->subscribe_moonraker({"extruder", "heater_bed"}, [](const json& update) { if (update.contains("extruder")) { float temp = update["extruder"].value("temperature", 0.0f); g_api->log_debug("Extruder temp: " + std::to_string(temp)); }});WARNING: The callback runs on a background thread. See Threading Model before updating UI.
unsubscribe_moonraker()
Section titled “unsubscribe_moonraker()”bool unsubscribe_moonraker(MoonrakerSubscriptionId id);Remove a Moonraker subscription. Returns true if found and removed.
Subject Registration
Section titled “Subject Registration”register_subject()
Section titled “register_subject()”void register_subject(const std::string& name, lv_subject_t* subject);Register an LVGL subject for reactive UI binding. Registered subjects can be referenced in XML layouts using bind_text, bind_flag_if_eq, etc.
Parameters:
name: Subject identifier (convention:"plugin_id.subject_name")subject: Pointer to yourlv_subject_t(you own the memory)
static lv_subject_t s_status_subject;
bool helix_plugin_init(PluginAPI* api, const char*) { lv_subject_init_pointer(&s_status_subject, "Idle"); api->register_subject("my-plugin.status", &s_status_subject); return true;}Then in XML:
<text_body bind_text="my-plugin.status"/>unregister_subject()
Section titled “unregister_subject()”bool unregister_subject(const std::string& name);Unregister a previously registered subject.
Service Registration
Section titled “Service Registration”The service registry enables plugin-to-plugin communication.
register_service()
Section titled “register_service()”void register_service(const std::string& name, void* service);Register a service for other plugins to use.
Parameters:
name: Service identifier (convention:"plugin_id.service_name")service: Pointer to your service instance (you own the memory)
class LedController {public: void set_color(uint32_t rgb); void set_brightness(float percent);};
static LedController s_controller;
bool helix_plugin_init(PluginAPI* api, const char*) { api->register_service("led-effects.controller", &s_controller); return true;}unregister_service()
Section titled “unregister_service()”bool unregister_service(const std::string& name);Remove a registered service.
get_service()
Section titled “get_service()”void* get_service(const std::string& name) const;
// Template version for conveniencetemplate<typename T>T* get_service(const std::string& name) const;Retrieve a service registered by another plugin.
auto* led = api->get_service<LedController>("led-effects.controller");if (led) { led->set_color(0xFF0000); // Red}UI Injection
Section titled “UI Injection”inject_widget()
Section titled “inject_widget()”bool inject_widget( const std::string& point_id, const std::string& xml_component, const WidgetCallbacks& callbacks = {});Inject an XML component into a designated injection point.
Parameters:
point_id: Injection point identifier (see table below)xml_component: Name of registered XML componentcallbacks: Optional lifecycle callbacks
Returns: true if injection succeeded.
api->inject_widget("panel_widget_area", "my_status_widget", { .on_create = [](lv_obj_t* widget) { g_api->log_debug("Widget created!"); }, .on_destroy = [](lv_obj_t* widget) { g_api->log_debug("Widget being destroyed"); }});register_xml_component()
Section titled “register_xml_component()”bool register_xml_component(const std::string& plugin_dir, const std::string& filename);Register an XML component from your plugin directory.
Parameters:
plugin_dir: The directory path passed tohelix_plugin_init()filename: XML file name (e.g.,"my_widget.xml")
bool helix_plugin_init(PluginAPI* api, const char* plugin_dir) { if (!api->register_xml_component(plugin_dir, "status_widget.xml")) { api->log_error("Failed to register status_widget.xml"); return false; }
api->inject_widget("panel_widget_area", "status_widget"); return true;}has_injection_point()
Section titled “has_injection_point()”bool has_injection_point(const std::string& point_id) const;Check if an injection point is currently available.
Note: Injection points are registered by panels when they load. If a panel hasn’t been shown yet, its injection points won’t be available.
Logging
Section titled “Logging”All logging methods are thread-safe and automatically prefix messages with your plugin ID.
void log_info(const std::string& message) const; // Visible with -vvoid log_warn(const std::string& message) const; // Always visiblevoid log_error(const std::string& message) const; // Always visiblevoid log_debug(const std::string& message) const; // Visible with -vvExample output:
[plugin:my-plugin] Initializing...UI Development
Section titled “UI Development”XML Component Registration
Section titled “XML Component Registration”Plugins can define UI components using HelixScreen’s declarative XML system.
my_widget.xml:
<?xml version="1.0" encoding="UTF-8"?><component> <view extends="lv_obj" name="my_status_widget" style_width="content" style_height="content" style_pad_all="#space_sm" style_radius="8" style_bg_color="#card_bg" style_bg_opa="cover">
<lv_obj style_flex_flow="row" style_flex_gap_row="#space_xs"> <icon name="icon" codepoint="0xF0425" style_text_color="#success_color"/> <text_body name="status" bind_text="my-plugin.status"/> </lv_obj>
</view></component>Register and inject in your plugin:
bool helix_plugin_init(PluginAPI* api, const char* plugin_dir) { // Register the XML component api->register_xml_component(plugin_dir, "my_widget.xml");
// Inject into home panel api->inject_widget("panel_widget_area", "my_status_widget");
return true;}Injection Points
Section titled “Injection Points”| Point ID | Location | Description |
|---|---|---|
panel_widget_area | Home panel | Main widget area below status |
print_status_extras | Print Status panel | Extra widgets area in print status overlay |
Note: More injection points may be added in future versions. Use has_injection_point() to check availability.
Widget Lifecycle Callbacks
Section titled “Widget Lifecycle Callbacks”struct WidgetCallbacks { std::function<void(lv_obj_t*)> on_create; // After widget added to container std::function<void(lv_obj_t*)> on_destroy; // Before widget is deleted};Use these to:
- Bind subjects to widget elements in
on_create - Clean up observers or custom data in
on_destroy
Design Tokens
Section titled “Design Tokens”Always use design tokens from globals.xml for consistent theming:
| Category | Wrong | Correct |
|---|---|---|
| Colors | style_bg_color="#E0E0E0" | style_bg_color="#card_bg" |
| Spacing | style_pad_all="12" | style_pad_all="#space_md" |
| Typography | <lv_label> | <text_heading>, <text_body>, <text_small> |
See LVGL9_XML_GUIDE.md for complete XML reference.
Threading Model
Section titled “Threading Model”CRITICAL: Understanding the threading model is essential to avoid crashes and race conditions.
Thread Overview
Section titled “Thread Overview”| Thread | Purpose | LVGL Safe? |
|---|---|---|
| Main thread | LVGL rendering, event callbacks | Yes |
| Background thread | Moonraker WebSocket callbacks | NO |
The Golden Rules
Section titled “The Golden Rules”- Event callbacks run on the main thread - Safe to update UI
- Moonraker callbacks run on a background thread - NOT safe for LVGL
- All
lv_*()functions must be called from the main thread only
The ui_async_call() Pattern
Section titled “The ui_async_call() Pattern”When you receive data on a background thread (e.g., Moonraker callback), use ui_async_call() to defer LVGL updates to the main thread:
#include "ui_update_queue.h"
api->subscribe_moonraker({"extruder"}, [](const json& update) { // This runs on BACKGROUND thread!
if (update.contains("extruder")) { float temp = update["extruder"].value("temperature", 0.0f);
// Defer LVGL update to main thread ui_async_call([temp]() { // This runs on MAIN thread - LVGL safe lv_subject_set_int(&s_temp_subject, static_cast<int>(temp)); }); }});WARNING: Never call
lv_subject_set_*(),lv_label_set_text(), or anylv_*()function directly from a Moonraker callback. This will cause crashes or undefined behavior.
What NOT to Do
Section titled “What NOT to Do”// BAD - Moonraker callback runs on background thread!api->subscribe_moonraker({"extruder"}, [](const json& update) { // CRASH: Calling LVGL from background thread lv_label_set_text(my_label, "Updated!"); // DON'T DO THIS lv_subject_set_int(&subject, 42); // DON'T DO THIS});
// GOOD - Defer to main threadapi->subscribe_moonraker({"extruder"}, [](const json& update) { ui_async_call([]() { lv_subject_set_int(&subject, 42); // Safe on main thread });});Common Patterns
Section titled “Common Patterns”Monitor Printer State
Section titled “Monitor Printer State”static PluginAPI* g_api = nullptr;static EventSubscriptionId s_temp_sub = 0;
bool helix_plugin_init(PluginAPI* api, const char*) { g_api = api;
// Check current temperature immediately PrinterState& state = api->printer_state(); float current = state.get_nozzle_temperature(); api->log_info("Current nozzle temp: " + std::to_string(current));
// Subscribe to temperature changes s_temp_sub = api->on_event(events::TEMPERATURE_UPDATED, [](const EventData& e) { std::string heater = e.payload.value("heater", ""); float temp = e.payload.value("current", 0.0f); float target = e.payload.value("target", 0.0f);
if (heater == "extruder" && temp > 200.0f) { g_api->log_warn("Nozzle is hot!"); } });
return true;}
void helix_plugin_deinit() { // Subscriptions are auto-cleaned, but explicit cleanup is good practice if (g_api && s_temp_sub != 0) { g_api->off_event(s_temp_sub); }}Respond to Print Events
Section titled “Respond to Print Events”bool helix_plugin_init(PluginAPI* api, const char*) { api->on_event(events::PRINT_STARTED, [](const EventData& e) { std::string file = e.payload.value("filename", "unknown"); g_api->log_info("Starting print: " + file);
// Maybe trigger LED effect, play sound, etc. });
api->on_event(events::PRINT_COMPLETED, [](const EventData&) { g_api->log_info("Print complete!");
// Maybe trigger celebration LED pattern if (auto* led = g_api->get_service<LedController>("led-effects.controller")) { led->play_effect("celebration"); } });
api->on_event(events::PRINT_ERROR, [](const EventData& e) { std::string error = e.payload.value("error", "Unknown error"); g_api->log_error("Print failed: " + error); });
return true;}Inter-Plugin Communication
Section titled “Inter-Plugin Communication”Provider plugin (led-effects):
class LedEffectsService {public: void set_mode(const std::string& mode) { current_mode_ = mode; } void set_color(uint32_t rgb) { color_ = rgb; } std::string get_mode() const { return current_mode_; }
private: std::string current_mode_ = "off"; uint32_t color_ = 0xFFFFFF;};
static LedEffectsService s_service;
bool helix_plugin_init(PluginAPI* api, const char*) { api->register_service("led-effects.service", &s_service); return true;}Consumer plugin:
bool helix_plugin_init(PluginAPI* api, const char*) { // Check if LED effects plugin is available auto* led = api->get_service<LedEffectsService>("led-effects.service"); if (led) { api->log_info("LED Effects available, current mode: " + led->get_mode()); led->set_mode("rainbow"); } else { api->log_info("LED Effects plugin not installed"); }
return true;}Building Plugins
Section titled “Building Plugins”Compiler Flags
Section titled “Compiler Flags”Required flags for plugin compilation:
CXXFLAGS = -std=c++17 -fPIC -O2 -Wall -WextraLDFLAGS = -sharedImportant:
-fPIC: Required for position-independent code in shared libraries-std=c++17: Minimum C++ standard version-shared: Create a shared library
Complete Makefile Template
Section titled “Complete Makefile Template”# Plugin Makefile TemplateCXX ?= g++CXXFLAGS = -std=c++17 -fPIC -O2 -Wall -WextraLDFLAGS = -shared
# HelixScreen include pathHELIX_ROOT ?= /path/to/helixscreenHELIX_INCLUDE = $(HELIX_ROOT)/include
# Plugin settingsPLUGIN_NAME = my-pluginSOURCES = $(wildcard *.cpp)OBJECTS = $(SOURCES:.cpp=.o)
# Output library name (platform-specific)ifeq ($(shell uname),Darwin) OUTPUT = libhelix_$(PLUGIN_NAME).dylib LDFLAGS += -dynamiclibelse OUTPUT = libhelix_$(PLUGIN_NAME).soendif
# Include pathsINCLUDES = -I$(HELIX_INCLUDE) -I$(HELIX_ROOT)/lib/lvgl \ -I$(HELIX_ROOT)/lib/spdlog/include \ -I$(HELIX_ROOT)/lib/nlohmann-json/include
# Build targetsall: $(OUTPUT)
$(OUTPUT): $(OBJECTS) $(CXX) $(LDFLAGS) -o $@ $^
%.o: %.cpp $(CXX) $(CXXFLAGS) $(INCLUDES) -c -o $@ $<
clean: rm -f $(OUTPUT) $(OBJECTS)
install: $(OUTPUT) mkdir -p $(DESTDIR)/plugins/$(PLUGIN_NAME) cp $(OUTPUT) manifest.json $(DESTDIR)/plugins/$(PLUGIN_NAME)/ @if [ -d xml ]; then cp -r xml/* $(DESTDIR)/plugins/$(PLUGIN_NAME)/; fi
.PHONY: all clean installCross-Compilation for Raspberry Pi
Section titled “Cross-Compilation for Raspberry Pi”# Pi cross-compilationpi: $(MAKE) CXX=aarch64-linux-gnu-g++ \ CXXFLAGS="$(CXXFLAGS) -march=armv8-a" \ OUTPUT=libhelix_$(PLUGIN_NAME).soCross-Compilation for Adventurer 5M
Section titled “Cross-Compilation for Adventurer 5M”# AD5M cross-compilation (MIPS architecture)ad5m: $(MAKE) CXX=mipsel-linux-gnu-g++ \ CXXFLAGS="$(CXXFLAGS) -march=mips32r2 -mfp32" \ OUTPUT=libhelix_$(PLUGIN_NAME).soDebugging
Section titled “Debugging”Verbosity Flags
Section titled “Verbosity Flags”Always use verbosity flags when testing plugins!
| Flag | Level | Usage |
|---|---|---|
| (none) | WARN | Never use for debugging |
-v | INFO | Basic logging |
-vv | DEBUG | Detailed logging (recommended) |
-vvv | TRACE | Very verbose |
./helix-screen --test --plugins-dir ./plugins -vvCommon Error Messages
Section titled “Common Error Messages”| Error Type | Message | Solution |
|---|---|---|
MANIFEST_PARSE_ERROR | ”JSON parse error: …” | Fix JSON syntax in manifest.json |
MANIFEST_MISSING_FIELD | ”Missing required field: id” | Add required fields to manifest |
LIBRARY_NOT_FOUND | ”No .so/.dylib file found” | Build your plugin library |
LOAD_FAILED | ”dlopen error: …” | Check library dependencies, linking |
SYMBOL_NOT_FOUND | ”helix_plugin_init not found” | Add extern "C" to entry points |
INIT_FAILED | ”Plugin init returned false” | Debug your init function |
VERSION_MISMATCH | ”API version mismatch” | Update plugin to current API version |
MISSING_DEPENDENCY | ”Dependency not found: …” | Install required plugin or remove dep |
DEPENDENCY_CYCLE | ”Plugin involved in dependency cycle” | Fix circular dependencies |
Testing Without a Printer
Section titled “Testing Without a Printer”Always use --test flag when no real printer is connected:
./helix-screen --test --plugins-dir ./plugins -vvThis enables mock data for printer state, temperatures, etc.
Debugging Tips
Section titled “Debugging Tips”-
Add timestamps to logs for timing issues:
api->log_debug("[" + std::to_string(time(nullptr)) + "] Event received"); -
Check if services exist before using:
if (!api->has_injection_point("panel_widget_area")) {api->log_warn("Injection point not available yet");} -
Validate manifest.json with a JSON linter before testing
-
Use on_destroy callbacks to verify cleanup:
.on_destroy = [](lv_obj_t*) {g_api->log_debug("Widget cleanup - verify no leaks");}
Best Practices and Gotchas
Section titled “Best Practices and Gotchas”- Store the
PluginAPI*pointer globally for use in callbacks - Use
extern "C"for all exported functions - Check for
nullptrbefore usingmoonraker_api(),moonraker_client(),config() - Use
ui_async_call()for LVGL updates from background threads - Prefix subjects and services with your plugin ID
- Use design tokens in XML for consistent theming
- Handle exceptions in callbacks (uncaught exceptions may crash HelixScreen)
- Clean up resources in
helix_plugin_deinit()
- Call
lv_*()functions from Moonraker callbacks (useui_async_call()) - Assume injection points are always available
- Block in event callbacks (keep them fast)
- Use raw pointers to PluginAPI after
helix_plugin_deinit() - Forget
extern "C"on entry points (causesSYMBOL_NOT_FOUND) - Hardcode colors - use design tokens for theme compatibility
- Create circular dependencies between plugins
- Assume Moonraker is always connected
Common Mistakes
Section titled “Common Mistakes”Mistake: Forgetting extern “C”
// WRONG - C++ name mangling breaks symbol lookupbool helix_plugin_init(PluginAPI* api, const char* dir);
// CORRECTextern "C" bool helix_plugin_init(PluginAPI* api, const char* dir);Mistake: LVGL from background thread
// WRONG - Moonraker callback is background threadapi->subscribe_moonraker({"extruder"}, [](const json& j) { lv_label_set_text(label, "crash incoming");});
// CORRECTapi->subscribe_moonraker({"extruder"}, [](const json& j) { ui_async_call([]() { lv_subject_set_string(&subject, "safe update"); });});Mistake: Not checking for nullptr
// WRONG - may crash if Moonraker not connectedapi->moonraker_api()->send_gcode("G28");
// CORRECTif (auto* mrapi = api->moonraker_api()) { mrapi->send_gcode("G28");}Complete Examples
Section titled “Complete Examples”Hello World (Minimal)
Section titled “Hello World (Minimal)”See Quick Start section for the complete minimal example.
Temperature Widget (UI Injection)
Section titled “Temperature Widget (UI Injection)”This example creates a custom temperature display widget and injects it into the home panel.
plugins/temp-widget/manifest.json:
{ "id": "temp-widget", "name": "Temperature Widget", "version": "1.0.0", "author": "HelixScreen", "description": "Displays nozzle temperature with custom styling", "ui": { "injection_points": ["panel_widget_area"] }}plugins/temp-widget/temp_display.xml:
<?xml version="1.0" encoding="UTF-8"?><component> <view extends="lv_obj" name="temp_display" style_width="160" style_height="80" style_pad_all="#space_md" style_radius="12" style_bg_color="#card_bg" style_bg_opa="cover" style_flex_flow="column" style_flex_main_place="center" style_flex_cross_place="center">
<text_small style_text_color="#text_muted">Nozzle</text_small>
<lv_obj style_flex_flow="row" style_flex_cross_place="center" style_flex_gap_column="#space_xs"> <icon codepoint="0xF18B6" style_text_color="#warning_color"/> <text_heading name="temp_value" bind_text="temp-widget.nozzle_temp"/> <text_body style_text_color="#text_muted">C</text_body> </lv_obj>
</view></component>plugins/temp-widget/temp_widget.cpp:
#include "plugin_api.h"#include "ui_update_queue.h"#include "lvgl.h"
#include <sstream>#include <iomanip>
using namespace helix::plugin;
static PluginAPI* g_api = nullptr;static std::string g_plugin_dir;
// Subject for reactive temperature displaystatic lv_subject_t s_nozzle_temp_subject;static char s_temp_buffer[16] = "---";
static void update_temperature(float temp) { std::ostringstream oss; oss << std::fixed << std::setprecision(1) << temp; strncpy(s_temp_buffer, oss.str().c_str(), sizeof(s_temp_buffer) - 1);
ui_async_call([]() { lv_subject_set_pointer(&s_nozzle_temp_subject, s_temp_buffer); });}
extern "C" bool helix_plugin_init(PluginAPI* api, const char* plugin_dir) { g_api = api; g_plugin_dir = plugin_dir;
// Initialize subject with default value lv_subject_init_pointer(&s_nozzle_temp_subject, s_temp_buffer); api->register_subject("temp-widget.nozzle_temp", &s_nozzle_temp_subject);
// Register XML component if (!api->register_xml_component(plugin_dir, "temp_display.xml")) { api->log_error("Failed to register temp_display.xml"); return false; }
// Subscribe to temperature events (main thread - safe for subjects) api->on_event(events::TEMPERATURE_UPDATED, [](const EventData& event) { if (event.payload.value("heater", "") == "extruder") { float temp = event.payload.value("current", 0.0f); update_temperature(temp); } });
// Subscribe to Moonraker for real-time updates api->subscribe_moonraker({"extruder"}, [](const json& update) { if (update.contains("extruder")) { float temp = update["extruder"].value("temperature", 0.0f); update_temperature(temp); } });
// Inject widget when home panel is available api->on_event(events::NAVIGATION_CHANGED, [](const EventData& event) { if (event.payload.value("panel", "") == "home") { if (g_api->has_injection_point("panel_widget_area")) { g_api->inject_widget("panel_widget_area", "temp_display"); } } });
// Try immediate injection (in case home is already showing) if (api->has_injection_point("panel_widget_area")) { api->inject_widget("panel_widget_area", "temp_display"); }
api->log_info("Temperature Widget initialized"); return true;}
extern "C" void helix_plugin_deinit() { if (g_api) { g_api->unregister_subject("temp-widget.nozzle_temp"); g_api->log_info("Temperature Widget shutdown"); } g_api = nullptr;}
extern "C" const char* helix_plugin_api_version() { return "1.0";}LED Effects Plugin (Full-Featured Reference)
Section titled “LED Effects Plugin (Full-Featured Reference)”This outline demonstrates a more complex plugin with services, events, and inter-plugin communication.
Key features:
- Exposes a service for other plugins to control LEDs
- Subscribes to print events to trigger effects
- Provides settings UI
- Communicates with Klipper via G-code
// plugins/led-effects/led_effects.cpp (outline)
class LedEffectsController {public: void set_effect(const std::string& effect); void set_color(uint32_t rgb); void set_brightness(float percent); std::string current_effect() const;
private: std::string m_effect = "off"; uint32_t m_color = 0xFFFFFF; float m_brightness = 1.0f;};
static LedEffectsController s_controller;static PluginAPI* g_api = nullptr;
extern "C" bool helix_plugin_init(PluginAPI* api, const char* plugin_dir) { g_api = api;
// Register service for other plugins api->register_service("led-effects.controller", &s_controller);
// Automatic effects based on print state api->on_event(events::PRINT_STARTED, [](const EventData&) { s_controller.set_effect("printing"); send_led_gcode(); });
api->on_event(events::PRINT_COMPLETED, [](const EventData&) { s_controller.set_effect("complete"); send_led_gcode(); });
api->on_event(events::PRINT_ERROR, [](const EventData&) { s_controller.set_effect("error"); send_led_gcode(); });
// Register settings UI api->register_xml_component(plugin_dir, "led_settings.xml"); api->inject_widget("settings_section", "led_settings");
return true;}
static void send_led_gcode() { if (auto* mrapi = g_api->moonraker_api()) { std::string gcode = "SET_LED LED=status_led " + s_controller.get_led_params(); mrapi->send_gcode(gcode); }}Known Limitations
Section titled “Known Limitations”These are documented limitations accepted in the current design.
Moonraker Subscription Persistence
Section titled “Moonraker Subscription Persistence”Once a plugin registers a Moonraker subscription via MoonrakerClient::register_notify_update(), the subscription cannot be removed until the client is destroyed. Calling unsubscribe_moonraker() marks the subscription as removed in plugin tracking, and the alive_flag_ pattern ensures callbacks are skipped. The actual WebSocket subscription persists until reconnect. This is minor overhead for long-running sessions.
Subject Name Collisions
Section titled “Subject Name Collisions”Subjects are registered globally. Two plugins using the same subject name will conflict. Always prefix with your plugin ID: "my-plugin.subject_name". There is no technical enforcement — convention is sufficient.
Single Injection Point Instance
Section titled “Single Injection Point Instance”Each injection point can only exist once in the UI at a time. Injected widgets are removed when navigating away from the panel. This matches HelixScreen’s single-panel navigation model. Plugins can re-inject on NAVIGATION_CHANGED events.
Subject Unregistration
Section titled “Subject Unregistration”Subjects registered via api->register_subject() are tracked but not actually unregistered from LVGL’s XML system during cleanup (LVGL 9.4 does not provide lv_xml_unregister_subject()). Re-loading a plugin with the same subject names works fine (registration is idempotent). Only affects development scenarios with frequent plugin reloads.
Future Directions
Section titled “Future Directions”These are enhancement ideas captured for future consideration. Implementation should be driven by actual user needs.
Filter System
Section titled “Filter System”Allow plugins to intercept and modify data, not just observe. Events are fire-and-forget; filters would allow transformation (e.g., G-code preprocessing, temperature calibration offsets). Would require a new FilterManager class. Implement when a real use case emerges.
Plugin Hot-Reload
Section titled “Plugin Hot-Reload”Reload plugins without restarting the application. Deferred due to complexity: LVGL widget cleanup, subject/observer lifecycle, service dependencies, state preservation, and dlclose() limitations. Current approach (restart app after changes) is simple and reliable.
Plugin Marketplace
Section titled “Plugin Marketplace”In-app discovery and installation of community plugins. Would require registry infrastructure, code signing, version compatibility matrix. Simpler alternatives: documented manual installation, curated plugin lists, GitHub releases, or a CLI install tool.
Plugin Sandboxing
Section titled “Plugin Sandboxing”Currently plugins are trusted code with full access (similar to VS Code extensions, Klipper macros, OctoPrint plugins). Sandboxing would require separate processes with IPC, capability-based permissions, and significantly increased complexity. The trust-based model is appropriate for the user base.
Additional Resources
Section titled “Additional Resources”- LVGL9_XML_GUIDE.md - Complete XML component reference
- ARCHITECTURE.md - HelixScreen system architecture
- DEVELOPMENT.md - Development environment setup
- TESTING.md - Testing infrastructure
This documentation is for HelixScreen Plugin API version 1.0. Please check for updates when upgrading HelixScreen versions.