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
├── locales/ # Optional i18n files
│ ├── en-US.json
│ └── zh-CN.json
└── 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.

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})
}

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,
}
})
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.