Skip to content

API Commands

RadiatorTwo edited this page Jun 14, 2026 · 7 revisions

Commands

A command is the unit a user assigns to a hardware button, simple key, or rotary encoder. This page covers the four types that describe and execute a command: IPluginCommand, IDisplayCommand, CommandDescriptor, CommandContext, and the ButtonTargets enum.

IPluginCommand

public interface IPluginCommand
{
    CommandDescriptor Descriptor       { get; }
    ButtonTargets     SupportedTargets { get; }
    Task              Execute(CommandContext ctx);
}

A single user-assignable action. The host runs commands on a background worker queue — Execute is invoked on a thread-pool thread, not on the UI thread. Two consequences:

  • Do not touch UI objects directly from Execute. If you need to interact with anything Avalonia-owned, marshal back through the host (e.g. by raising an event the host listens on, or by calling Dispatcher.UIThread.Post in code you own).
  • Do not .Wait() or .Result on the returned Task. Block the worker and you stall every following command in the queue — return the Task and let the host await it.

Exceptions thrown by Execute are caught and logged by the host but do not unload the plugin. Wrap risky calls in try/catch and call ctx.Host.Logger.Error(msg, ex) so the failure is attributed to your command rather than appearing as a generic host error.

Example

internal sealed class HelloCommand : IPluginCommand
{
    public CommandDescriptor Descriptor { get; } = new()
    {
        CommandName = "Hello.SayHello",
        DisplayName = "Say hello",
        Group       = "Hello"
    };

    public ButtonTargets SupportedTargets => ButtonTargets.TouchButton | ButtonTargets.SimpleButton;

    public Task Execute(CommandContext ctx)
    {
        try { ctx.Host.Logger.Info($"Hello from {ctx.Target}!"); }
        catch (Exception ex) { ctx.Host.Logger.Error("SayHello failed", ex); }
        return Task.CompletedTask;
    }
}

IDisplayCommand

public interface IDisplayCommand : IPluginCommand
{
    TimeSpan UpdateInterval { get; }
    string   GetText(CommandContext ctx);
}

Extends IPluginCommand with text that is polled and re-rendered on the touch button. Use it for clocks, sensor readings, current scene names, queue depths, etc.

  • Set SupportedTargets to ButtonTargets.TouchButton — only touch buttons render text.
  • UpdateInterval is the polling cadence. Pick the longest interval that still feels live; 1–5 seconds is usually right.
  • GetText runs on the polling timer and must be fast and synchronous. Cache the latest value in a field that your async data path updates, and just return that field here.

If your data arrives via push (event subscription) and you want an immediate redraw without waiting for the next poll, call host.RequestButtonRefresh(Descriptor.CommandName).

Example

internal sealed class HelloDisplayCommand : IDisplayCommand
{
    public CommandDescriptor Descriptor { get; } = new()
    {
        CommandName = "Hello.Display",
        DisplayName = "Hello display",
        Group       = "Hello"
    };

    public ButtonTargets SupportedTargets => ButtonTargets.TouchButton;
    public TimeSpan      UpdateInterval   => TimeSpan.FromSeconds(1);

    public string GetText(CommandContext ctx) => $"Hello {DateTime.Now:HH:mm:ss}";

    public Task Execute(CommandContext ctx) => Task.CompletedTask; // tap = no-op
}

IAdjustmentCommand

public interface IAdjustmentCommand : IPluginCommand
{
    Task    ApplyAdjustment(CommandContext ctx, int ticks);
    Task    ApplyReset     (CommandContext ctx);
    string? GetValueText   (CommandContext ctx);
}

A rotary-encoder value adjustment: a continuous parameter the user dials up/down (volume, brightness, zoom), with a press to reset or commit. Set SupportedTargets to ButtonTargets.RotaryEncoder.

Member Notes
ApplyAdjustment(ctx, ticks) Applies a relative change. ticks is positive for a right-turn and negative for a left-turn. Multiple physical detents within one host tick are summed, so ticks may be ±2, ±3… — don't assume ±1.
ApplyReset(ctx) Typically invoked by an encoder press. Snap the value back to a sensible default or commit a pending change.
GetValueText(ctx) Current value as short display text (e.g. "75 %"), or null for no overlay. Called by the host when rendering a dial indicator. Must be fast and synchronous.

Forward-compatible contract. Host-side dispatch for IAdjustmentCommand is added alongside the first plugin that needs it. Until then, expose three discrete commands (Foo.Up, Foo.Down, Foo.Reset) bound to the rotary's left/right/press slots and branch on ctx.Target inside Execute. Implementing IAdjustmentCommand in addition is safe and forward-compatible — once the host wires it up, your commands light up automatically.

Example

internal sealed class VolumeAdjustment : IAdjustmentCommand
{
    public CommandDescriptor Descriptor { get; } = new()
    {
        CommandName = "MyPlugin.Volume",
        DisplayName = "Volume",
        Group       = "MyPlugin"
    };

    public ButtonTargets SupportedTargets => ButtonTargets.RotaryEncoder;

    public Task ApplyAdjustment(CommandContext ctx, int ticks)
    {
        var step = ticks * 5;                     // 5 % per detent
        _value = Math.Clamp(_value + step, 0, 100);
        ctx.Host.RequestButtonRefresh(Descriptor.CommandName);
        return Task.CompletedTask;
    }

    public Task ApplyReset(CommandContext ctx)
    {
        _value = 50;
        ctx.Host.RequestButtonRefresh(Descriptor.CommandName);
        return Task.CompletedTask;
    }

    public string? GetValueText(CommandContext ctx) => $"{_value} %";

    // Fallback dispatch until the host wires up IAdjustmentCommand:
    public Task Execute(CommandContext ctx) => Task.CompletedTask;

    private int _value = 50;
}

IDisplayImageCommand

public interface IDisplayImageCommand : IPluginCommand
{
    TimeSpan UpdateInterval { get; }
    bool     RenderImage(CommandContext ctx, IRenderCanvas canvas);
}

Draws a dynamic touch-button image onto a host-provided IRenderCanvas (90×90), using the host's primitives so its text and symbols stay visually consistent with the core. Use it for album art, camera thumbnails, scene previews, gauges — any visual that changes at runtime. Composite a fully custom bitmap with canvas.DrawImage(bytes, …).

  • Set SupportedTargets to ButtonTargets.TouchButton — only touch buttons render images.
  • UpdateInterval is the polling cadence the host uses to call RenderImage.
  • RenderImage must be fast and synchronous — it runs on a polling timer. Do any decoding/network work asynchronously (kick it off from LoupixPlugin.Initialize or an event handler) and cache the result; draw from the cache here. Return true when you drew, or false to leave the button unchanged (e.g. no data yet).
  • For an immediate redraw outside the poll cadence, call host.RequestButtonRefresh(Descriptor.CommandName).

This command's output is owned by the host: it materializes as a managed layer on the touch button and is swept when the command is removed. The user can move, scale and reorder the layer in the button editor, but not edit its content.

Example

internal sealed class NowPlayingArt : IDisplayImageCommand
{
    private byte[]? _cachedArt;       // updated by the async data path; same array reused per frame
    private string  _caption = "";

    public CommandDescriptor Descriptor { get; } = new()
    {
        CommandName = "MyPlugin.NowPlaying",
        DisplayName = "Now playing",
        Group       = "MyPlugin"
    };

    public ButtonTargets SupportedTargets => ButtonTargets.TouchButton;
    public TimeSpan      UpdateInterval   => TimeSpan.FromSeconds(2);

    public bool RenderImage(CommandContext ctx, IRenderCanvas canvas)
    {
        if (_cachedArt is null) return false;                 // no data → leave button unchanged
        canvas.DrawImage(_cachedArt, 0, 0, canvas.Width, canvas.Height);
        if (_caption.Length > 0)
            canvas.DrawText(_caption, 0, canvas.Height - 20, canvas.Width, 20,
                PluginColor.White, fontSize: 12f, centered: true, outlined: true, outlineColor: PluginColor.Black);
        return true;
    }

    public Task Execute(CommandContext ctx) => Task.CompletedTask;

    // Called from your async data path:
    internal void Update(byte[] png, string caption, IPluginHost host)
    {
        _cachedArt = png;
        _caption   = caption;
        host.RequestButtonRefresh(Descriptor.CommandName);
    }
}

CommandDescriptor

public sealed class CommandDescriptor
{
    public required string  CommandName       { get; init; }
    public required string  DisplayName       { get; init; }
    public required string  Group             { get; init; }
    public string?          ParameterTemplate { get; init; }
    public IReadOnlyList<CommandParameter> Parameters { get; init; } = [];
    public bool             HiddenFromMenu    { get; init; }
}

public sealed class CommandParameter(string name, Type parameterType)
{
    public string Name          { get; }
    public Type   ParameterType { get; }
}
Member Notes
CommandName Stable identifier persisted into user button configurations. Treat it as a public API — renaming it later breaks every saved config that referenced it. Convention: <Plugin>.<Action>, PascalCase.
DisplayName Label in the command-selection menu.
Group Submenu/category. Plugins usually use their Metadata.Name.
ParameterTemplate Placeholder rendered in the command builder, e.g. ({SceneName}). Null when the command takes no parameters.
Parameters Positional parameter definitions in declaration order. Used by the command builder for editors/validation.
HiddenFromMenu When true the command is not listed as a plain leaf; it surfaces only through dynamic submenus built by IMenuContributor (e.g. one entry per OBS scene). The command stays fully registered and executable.

CommandContext

public sealed class CommandContext
{
    public required string[]      Parameters  { get; init; }
    public required ButtonTargets Target      { get; init; }
    public int?                   SourceIndex { get; init; }
    public DeviceInfo?            Device      { get; init; }
    public required IPluginHost   Host        { get; init; }
}

Everything a command needs at execution time.

Member Notes
Parameters Positional parameter values exactly as parsed from the persisted command string's parentheses. Never null; empty when the command takes none. Strings — parse to typed values yourself based on the declared CommandParameter types.
Target Which button type triggered the command. Useful when a single command should behave differently on a rotary encoder vs. a touch button.
SourceIndex Identifier of the originating control when Target denotes an indexed source — rotary index for RotaryEncoder, touch slot for TouchButton, simple-button index for SimpleButton. null when the command was invoked from a chained command or from the CLI. Pair with IPluginHost.GetTouchSlotForRotary to address the touch slot next to the rotary that fired.
Device The active device, or null if none. Mirrors IPluginHost.ActiveDevice.
Host The plugin's IPluginHost. Convenient when a command instance is shared across plugins or when you do not want to capture the host in a closure.

ButtonTargets

[Flags]
public enum ButtonTargets
{
    None          = 0,
    TouchButton   = 1,
    SimpleButton  = 2,
    RotaryEncoder = 4,
    All           = TouchButton | SimpleButton | RotaryEncoder
}

Flags enum. SupportedTargets filters the command-selection menu of each button type:

  • A user editing a touch button only sees commands whose SupportedTargets include TouchButton.
  • IDisplayCommand makes no sense on a simple key (no display) — use ButtonTargets.TouchButton.
  • RotaryEncoder covers all three rotation/press events; the host invokes Execute for each, and the command should branch on ctx.Target (or on a parameter) if it needs to distinguish.

Clone this wiki locally