Skip to main content

Controller Plugin Guide

Controller plugins are hardware device drivers. They handle communication with physical RGB devices over Serial, HID, or other protocols.

Directory Structure​

plugins/controller.my_device/
├── manifest.json # Plugin metadata + hardware match rules
├── main.lua # Entry script with lifecycle hooks
├── lib/ # Optional Lua modules
│ ├── protocol.lua # Wire protocol implementation
│ └── config.lua # Device configuration
└── data/ # Runtime data (created by Core)

Lifecycle​

Device Discovery (USB VID/PID + interface_number match)
→ Open device handle (serial/HID)
→ plugin.on_validate() ← Claim or reject device
→ Failed? Close handle, try next plugin
→ Succeeded? Continue
→ plugin.on_init() ← Register output ports
→ [Render loop] plugin.on_tick(dt) ← Send colors to hardware
→ plugin.on_shutdown() ← Cleanup
info

For HID devices, if interface_number is specified in manifest.json match rules, Core filters candidates before opening a device handle. Only the matching HID interface is offered to on_validate(), eliminating the need for manual interface checks in Lua code.

This HID workflow is already verified and is the recommended approach for multi-interface HID devices.

For Serial (CDC) devices, interface_number matching is also supported, allowing you to target a specific USB CDC interface on composite devices. See device:interface_number() for reading the interface number at runtime.

warning

Serial interface_number matching has not been verified in production and is not recommended. Available in version 3.0.1 and later.

:::

Lifecycle Hooks​

on_validate()​

Called to determine if this plugin can drive the detected device. Use this to send a handshake command and verify the device identity.

Must return: true to claim the device, false to pass to the next plugin.

tip

Core pre-populates manufacturer and model from the USB descriptor. You only need to call set_manufacturer() / set_model() if you want to override the default values (e.g. with a more specific model name obtained from a handshake).

function plugin.on_validate()
-- Send identification command
device:write("\x01\x00")
local response = device:read(8, 500) -- 8 bytes, 500ms timeout

if response and #response >= 4 then
-- Override model with device-reported name (optional)
device:set_model("LED Strip v2")
return true
end

return false
end

on_init()​

Called after successful validation. Register the device's output ports here.

function plugin.on_init()
device:add_output({
id = "main",
name = "LED Strip",
type = "linear",
size = 144,
capabilities = {
editable = false,
min_total_leds = 1,
max_total_leds = 300,
}
})
end

on_tick(dt)​

Called every frame. Read the mixed color data and send it to the hardware.

  • dt — Time since last tick in seconds (float)
function plugin.on_tick(dt)
local rgb = device:get_rgb_bytes("main")
-- Build protocol packet and send
local header = string.char(0x02, #rgb // 3)
device:write(header .. rgb)
end

on_shutdown()​

Called when the device disconnects or the plugin is unloaded. Clean up resources and optionally blank the LEDs.

function plugin.on_shutdown()
local count = device:output_led_count("main") or 0
if count > 0 then
-- Send all-black frame
local blank = string.rep("\x00\x00\x00", count)
device:write("\x02" .. string.char(count) .. blank)
end
end

Output Port Types​

TypeDescription
"single"Single-color light (1 LED)
"linear"Linear LED strip
"matrix"2D LED matrix grid

Matrix Output​

For matrix-type outputs, provide a matrix field:

device:add_output({
id = "panel",
name = "LED Panel",
type = "matrix",
size = 144,
matrix = {
width = 12,
height = 12,
map = {1, 2, 3, ..., 144} -- Pixel mapping, -1 = unmapped
}
})

The map array defines how logical LED indices map to physical positions. Use -1 for dead pixels.

Output Capabilities​

capabilities = {
editable = true, -- Can the user resize this output?
min_total_leds = 1, -- Minimum LED count
max_total_leds = 512, -- Maximum LED count
allowed_total_leds = nil, -- Specific allowed counts (e.g. {60, 120, 144})
}

Default Effect​

Version

Available since 3.0.0-dev.4.

You can specify a default_effect for an output port. When set, this effect is automatically applied when no user configuration exists for the output.

device:add_output({
id = "strip",
name = "RGB Strip",
type = "linear",
size = 60,
default_effect = "rainbow_wave",
})

Complete Example​

-- plugins/controller.my_serial_strip/main.lua
local plugin = {}

local HEADER = 0xAA
local CMD_SET_LEDS = 0x01
local CMD_IDENTIFY = 0x02

function plugin.on_validate()
device:write(string.char(HEADER, CMD_IDENTIFY))
local resp = device:read(16, 1000)

if not resp or #resp < 4 then
return false
end

local model_len = string.byte(resp, 3)
local model = resp:sub(4, 3 + model_len)

-- Override with device-reported values (optional, USB descriptor is used as default)
device:set_manufacturer("MyCompany")
device:set_model(model)
device:set_device_type("light")
return true
end

function plugin.on_init()
device:add_output({
id = "strip",
name = "RGB Strip",
type = "linear",
size = 60,
capabilities = {
editable = true,
min_total_leds = 1,
max_total_leds = 300,
},
default_effect = "rainbow_wave", -- optional
})
end

function plugin.on_tick(_dt)
local rgb = device:get_rgb_bytes("strip")
local count = device:output_led_count("strip")
-- Protocol: HEADER, CMD, COUNT_HIGH, COUNT_LOW, R,G,B,...
local packet = string.char(HEADER, CMD_SET_LEDS,
math.floor(count / 256), count % 256) .. rgb
device:write(packet)
end

function plugin.on_shutdown()
local count = device:output_led_count("strip") or 0
if count > 0 then
local blank = string.rep("\x00\x00\x00", count)
local packet = string.char(HEADER, CMD_SET_LEDS,
math.floor(count / 256), count % 256) .. blank
device:write(packet)
end
end

return plugin

HID Devices​

For HID protocol devices, use the HID-specific I/O methods:

-- Send a HID feature report
device:hid_send_feature_report(packet)
device:hid_send_feature_report(packet, length, selector)

-- Read a HID feature report
local data = device:hid_get_feature_report(length)
local data = device:hid_get_feature_report(length, report_id, selector)

-- List HID interfaces
local interfaces = device:hid_interfaces()

Interface Filtering​

Many HID devices expose multiple interfaces (e.g. keyboard input on interface 0, lighting control on interface 2). You can declare the target interface in manifest.json so Core handles the filtering:

"rules": [
{ "vid": "0x1532", "pid": "0x024E", "interface_number": 3 }
]

This is the recommended approach. If you omit interface_number, all interfaces matching VID/PID will be offered to your plugin, and you can filter manually in on_validate() using device:hid_interfaces().

Multi-Interface Communication​

Some devices require communication across multiple HID interfaces (e.g. control commands on interface 0, LED streaming on interface 1). In this case:

  1. Set interface_number to your primary (control) interface in the match rule.
  2. Use device:hid_interfaces() at runtime to discover companion interfaces.
  3. Use the selector parameter in hid_send_feature_report() / hid_get_feature_report() to target a specific companion interface.
function plugin.on_validate()
-- Primary interface is already opened by Core (matched via manifest)
-- Find companion interface for LED streaming
local interfaces = device:hid_interfaces()
for _, info in ipairs(interfaces) do
if info.interface_number == 1 and not info.primary then
state.led_selector = info.port_key
end
end
return true
end

See the Controller API Reference for the complete device object API.