-
Notifications
You must be signed in to change notification settings - Fork 1
API 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.
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 callingDispatcher.UIThread.Postin code you own). - Do not
.Wait()or.Resulton the returnedTask. Block the worker and you stall every following command in the queue — return theTaskand 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.
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;
}
}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
SupportedTargetstoButtonTargets.TouchButton— only touch buttons render text. -
UpdateIntervalis the polling cadence. Pick the longest interval that still feels live; 1–5 seconds is usually right. -
GetTextruns 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).
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
}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
IAdjustmentCommandis 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 onctx.TargetinsideExecute. ImplementingIAdjustmentCommandin addition is safe and forward-compatible — once the host wires it up, your commands light up automatically.
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;
}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
SupportedTargetstoButtonTargets.TouchButton— only touch buttons render images. -
UpdateIntervalis the polling cadence the host uses to callRenderImage. -
RenderImagemust be fast and synchronous — it runs on a polling timer. Do any decoding/network work asynchronously (kick it off fromLoupixPlugin.Initializeor an event handler) and cache the result; draw from the cache here. Returntruewhen you drew, orfalseto 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.
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);
}
}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
SupportedTargetstoButtonTargets.TouchButton— only touch buttons render images. -
RenderAnimatedFrameis called off the UI thread, up toTargetFpstimes a second. It must be fast and synchronous — do decoding/network work asynchronously (kick it off fromLoupixPlugin.Initializeor an event handler) and cache the result; draw from the cache here. - Return one of the
AnimationFrameInfofactory 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 implementingIDisplayImageCommand; implement this one only for real animation. A command may implement both — the host prefers the animated path.
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".
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
FrameNumberdiffers 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).
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.
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. |
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. |
[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
SupportedTargetsincludeTouchButton. -
IDisplayCommandmakes no sense on a simple key (no display) — useButtonTargets.TouchButton. -
RotaryEncodercovers all three rotation/press events; the host invokesExecutefor each, and the command should branch onctx.Target(or on a parameter) if it needs to distinguish.
Getting started
API reference
Advanced
Operations
Release notes