Skip to content

API Commands

RadiatorTwo edited this page Jun 28, 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);
    }
}

IAnimatedDisplayCommand

public interface IAnimatedDisplayCommand : IPluginCommand
{
    int TargetFps { get; }
    AnimationFrameInfo RenderAnimatedFrame(CommandContext ctx, IRenderCanvas canvas, AnimationFrameContext frame);
}

Draws an animated touch-button image onto a host-provided IRenderCanvas (90×90). Where IDisplayImageCommand is repainted on a slow polling timer, this command is driven by the host's central animation scheduler at a target frame rate — use it for spinners, progress sweeps, sprite loops, pulsing indicators, any genuine motion.

  • Set SupportedTargets to ButtonTargets.TouchButton — only touch buttons render images.
  • RenderAnimatedFrame is called off the UI thread, up to TargetFps times a second. It must be fast and synchronous — do decoding/network work asynchronously (kick it off from LoupixPlugin.Initialize or an event handler) and cache the result; draw from the cache here.
  • Return one of the AnimationFrameInfo factory results so the host knows whether to push the button to the device this tick.

Independent and optional — existing plugins are unaffected. This is a separate interface, not a change to IDisplayImageCommand. A command that only needs a slow periodic redraw should keep implementing IDisplayImageCommand; implement this one only for real animation. A command may implement both — the host prefers the animated path.

AnimationFrameContext

public readonly struct AnimationFrameContext
{
    public long     FrameNumber  { get; init; }
    public TimeSpan Elapsed      { get; init; }
    public TimeSpan Delta        { get; init; }
    public int      EffectiveFps { get; init; }
}

The timing snapshot the host hands to each RenderAnimatedFrame call.

Member Notes
FrameNumber Zero-based frame counter since the host started ticking this command.
Elapsed Total wall-clock time since ticking started. Drive your visual from this, not from a tick count — playback then stays correct even when the host clamps the effective rate below TargetFps.
Delta Wall-clock time since the previous frame.
EffectiveFps The rate the host is actually ticking at, after clamping TargetFps to its global animation FPS limit.

TargetFps is your desired rate; the host clamps it to a global limit, so the real rate may be lower — read EffectiveFps for the truth. A value <= 0 means "use the host's default limit".

AnimationFrameInfo

public readonly struct AnimationFrameInfo
{
    public long FrameNumber { get; }
    public bool Drawn       { get; }
    public bool IsFinal     { get; }

    public static AnimationFrameInfo Skip();                  // nothing changed this tick
    public static AnimationFrameInfo Frame(long frameNumber); // drew a frame; keep animating
    public static AnimationFrameInfo Final(long frameNumber); // drew the last frame; stop & hold it
}

What RenderAnimatedFrame returns. Construct it with the factory helpers, not directly:

Result Use when Host behaviour
Skip() You drew nothing this tick (e.g. data not ready yet). Leaves the button unchanged — no device push.
Frame(n) You drew a frame and the animation continues. Pushes the button only if n differs from the last pushed number.
Final(n) You drew the last frame of a one-shot animation. Pushes it, then stops ticking the command and holds that frame on screen.

The frame number is the host's dirty key. The host only pushes the button to the device when the returned FrameNumber differs from the previously pushed one, so returning the same number twice costs no device I/O. For a discrete sprite loop, return the sprite index; for continuous motion, return a value that changes every frame (e.g. frame.FrameNumber).

Example

internal sealed class Spinner : IAnimatedDisplayCommand
{
    public CommandDescriptor Descriptor { get; } = new()
    {
        CommandName = "MyPlugin.Spinner",
        DisplayName = "Loading spinner",
        Group       = "MyPlugin"
    };

    public ButtonTargets SupportedTargets => ButtonTargets.TouchButton;
    public int           TargetFps        => 30;   // host clamps to its global limit

    public AnimationFrameInfo RenderAnimatedFrame(CommandContext ctx, IRenderCanvas canvas, AnimationFrameContext frame)
    {
        // Drive the angle from wall-clock time, so it spins at the right speed
        // even when the host ticks slower than TargetFps.
        var angle = (float)(frame.Elapsed.TotalSeconds * 180.0 % 360.0);   // 180°/s

        canvas.Clear(PluginColor.Transparent);
        canvas.PushTransform();
        canvas.Translate(canvas.Width / 2f, canvas.Height / 2f);
        canvas.Rotate(angle);
        canvas.DrawArc(-30, -30, 60, 60, startAngle: 0f, sweepAngle: 270f, strokeWidth: 6, PluginColor.White);
        canvas.PopTransform();

        return AnimationFrameInfo.Frame(frame.FrameNumber);   // continuous → never Final
    }

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

For a one-shot animation, return AnimationFrameInfo.Final(n) on the last frame so the host stops ticking and holds it; return AnimationFrameInfo.Skip() on any tick where you have nothing new to draw.

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