Variables
An integration exposes variables by implementing IVariableProvider. For most plugins that means two
members: a list of VariableDefinitions and a ReadAsync that returns the current value of one of them.
Quick start
Section titled “Quick start”using MacroDeck.Sdk;using MacroDeck.Sdk.Variables;
public sealed class MusicPlayerIntegration : IPluginIntegration, IVariableProvider{ private readonly PlaybackEngine _engine = new();
public IReadOnlyList<VariableDefinition> Variables { get; } = [ VariableDefinition.Eager("music_track", VariableType.Text) with { Id = "track" }, VariableDefinition.Eager("music_is_playing", VariableType.Boolean) with { Id = "is-playing" }, VariableDefinition.Eager("music_position", VariableType.Numeric, refreshInterval: TimeSpan.FromSeconds(1)) with { Id = "position", Unit = "s", SemanticKind = VariableSemanticKinds.Duration } ];
public ValueTask<VariableReading> ReadAsync(string localId, CancellationToken cancellationToken = default) => ValueTask.FromResult(localId switch { "track" => VariableReading.Of(_engine.CurrentTrack.Title), "is-playing" => VariableReading.Of(_engine.IsPlaying), "position" => VariableReading.Of(_engine.PositionSeconds), _ => VariableReading.Unavailable });
// IPluginIntegration members omitted.}The user now has {{ vars.music_track }}, {{ vars.music_is_playing }} and {{ vars.music_position }}.
The host calls ReadAsync for each variable on its own refresh interval - you never push eager values.
Three things to know:
Nameis what the user types,music_track. Lowercase,[a-z0-9_], and prefix it with your plugin so it does not collide with another one.Idis whatReadAsyncreceives and what ends up in saved profiles. Leave it out and the host derives one from the name - but setting it explicitly keeps yourswitchreadable and lets you rename the variable later without breaking anyone’s configuration.- A value is a
string, a number or abool. Anything else, andVariableReading.Unavailable, is shown as “not available” - use that for “no value right now” (not connected, not configured) rather than returning""or0.
Defining a variable
Section titled “Defining a variable”VariableDefinition.Eager(name, type, decimalPlaces, refreshInterval) covers the common case; add
anything else with with { ... }.
| Property | What it does | Example |
|---|---|---|
Name |
Variable name in templates. | "weather_temperature" |
Id |
Local id passed to ReadAsync / SetValueAsync. Stable once shipped. |
"temperature" |
Type |
Text, Numeric or Boolean. |
VariableType.Numeric |
DisplayName, Description |
Localized text shown in the variable picker. | Strings.Variables.Temperature() |
Unit |
Symbol shown next to the value, reachable as vars.x.unit. |
"°C", "%", "GB" |
SemanticKind |
How the host formats it - see below. | VariableSemanticKinds.Percentage |
DecimalPlaces |
Digits shown for a numeric value. | 1 |
RefreshInterval |
How often the host calls ReadAsync. Host default when null. |
TimeSpan.FromSeconds(5) |
Write |
Makes the variable writable - see Writable variables. | new VariableWriteCapability() |
Attributes |
Free-form strings, readable as vars.x.<key>. Not interpreted by the host. |
new Dictionary<string, string> { ["room"] = "office" } |
Configuration |
Groups the variable under one configured instance (two OBS connections). | new VariableConfiguration(entryId, "Studio PC") |
SemanticKind tells the host how to render a number; Unit is what it shows next to it:
SemanticKind |
Unit |
stored | rendered |
|---|---|---|---|
duration |
s |
187 |
03:07 |
percentage |
% |
12.5 |
12.5 % |
bytes |
B |
1536 |
1.5 KB |
none |
fps |
60 |
60 fps |
An unknown kind renders as a plain number with its unit, so naming a newer one is never an error. Use
bytes only for a value that really is in bytes - a value already in GB is none with a GB unit.
A provider may declare at most VariableLimits.MaxEagerVariablesPerProvider (256) eager variables; the
host keeps the first 256 and logs an error. More than that belongs in the catalog.
Writable variables
Section titled “Writable variables”A writable variable is how a Slider widget gets two-way binding: the slider writes through
SetValueAsync and reads the real value back through ReadAsync. Declare Write and implement
SetValueAsync:
public IReadOnlyList<VariableDefinition> Variables { get; } =[ VariableDefinition.Eager("music_volume", VariableType.Numeric) with { Id = "volume", Unit = "%", SemanticKind = VariableSemanticKinds.Percentage, Write = new VariableWriteCapability() }];
public ValueTask<VariableReading> ReadAsync(string localId, CancellationToken cancellationToken = default) => ValueTask.FromResult(localId switch { // min, max and step give a bound Slider its range. "volume" => VariableReading.Of(_engine.VolumePercent, 0, 100, 1), _ => VariableReading.Unavailable });
public ValueTask<VariableWriteResult> SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default){ if (value is not (double or int or long)) { return ValueTask.FromResult(VariableWriteResult.InvalidValue()); }
_engine.SetVolume((int)Convert.ToDouble(value, CultureInfo.InvariantCulture)); // clamps to 0-100 return ValueTask.FromResult(VariableWriteResult.Applied());}- The host only calls
SetValueAsyncfor a variable that declaresWrite; every other write is refused before it reaches you, so there is no need to checklocalIdagainst a read-only list. Appliedmeans you applied it. The host does not echo the requested value - the nextReadAsyncis the truth, so clamping or rounding needs no extra work.- The other results are
NotWritable,NotFound,Unavailable(can write, just not right now - e.g. disconnected),InvalidValueandFailed. Never declareWriteand then answerNotWritable: that is a slider that silently does nothing, and conformance check MDC0314 fails it. - Set
Write = new VariableWriteCapability { CommitOnRelease = true }when every intermediate value of a drag would be disruptive (seeking a track). Leave it off for volume, where live feedback is the point.
Min, Max and Step come from the reading rather than the definition because they can change - a
seek bar’s maximum is the current track’s length.
Variables that depend on configuration
Section titled “Variables that depend on configuration”Variables may change with configuration. After the configuration changed, tell the host to read it
again with CatalogChanged(CapabilityKinds.Variables) on an injected IPluginCatalogNotifier - the
weather sample does this after its config flow.
DeclaredVariables is what the host shows for an integration that is not configured yet. It defaults to
Variables; override it only when Variables is empty until something is configured, and set
VariablesDependOnConfiguration when its names contain a VariableNameTemplate placeholder for a
per-instance segment.
Testing
Section titled “Testing”PluginTestHarness reads and writes variables the way the host does:
await using var harness = /* your harness setup */;await harness.InitializeIntegrationsAsync();
var track = (await harness.Variables.GetAsync("track")).DataAs<VariableReadingDto>();Assert.That(track!.Value.Text, Is.EqualTo("Intro"));
var written = (await harness.Variables.SetAsync("volume", new VariableValueDto { Kind = "number", Number = 35 })).DataAs<VariableSetResult>();Assert.That(written!.Status, Is.EqualTo("Applied"));GetAsync and SetAsync take the local id, not the name. The
sample plugins test every variable they declare this way.
Templates
Section titled “Templates”A variable is {{ vars.<name> }} in any template; its static attributes are suffixes on the same
reference: {{ vars.cpu.unit }}, {{ vars.room_sensor.room }}.
vars.<name>.state is computed by the host and tells an unavailable variable apart from an empty one:
| member | true when |
|---|---|
state.is_available |
the reference resolved to a value |
state.is_not_available |
it did not - unknown name, or a provider that went quiet |
state.is_empty |
it resolved and renders as zero characters |
state.is_not_empty |
it resolved and renders as at least one character |
{% if vars.music_artist.state.is_not_empty %}By {{ vars.music_artist }}{% endif %}Because state is resolved first, an Attributes key named state is unreachable. state works on
vars references only, not on event parameters or script inputs.
The variable catalog
Section titled “The variable catalog”Use the catalog when your variables are a runtime resource space too large to declare up front - Home
Assistant entities, OBS sources, MQTT topics. Nothing is registered until the user picks a resource in
the variable browser; only then does it become an ordinary {{ vars.name }} variable. A provider can
have eager variables and a catalog at the same time.
public sealed class Foobar2000Integration : IPluginIntegration, IVariableProvider{ private readonly Foobar2000Client _client;
public IReadOnlyList<VariableDefinition> Variables { get; } = [];
public bool SupportsCatalog => true;
public bool SupportsSearch => true;
public string CatalogName => "foobar2000";
public async ValueTask<VariableCatalogPage> DiscoverAsync( VariableCatalogQuery query, CancellationToken cancellationToken = default) { if (!_client.IsConnected) { return VariableCatalogPage.Empty; }
// The client's cursor is handed straight through as the continuation token. var page = await _client.GetCustomTagsAsync(query.Search, query.PageSize, query.ContinuationToken, cancellationToken);
return new VariableCatalogPage { Items = page.Tags .Select(tag => VariableDefinition.OnDemand(tag.Name, VariableType.Text) with { Name = $"foobar_{tag.Name}", }) .ToList(), ContinuationToken = page.NextCursor, }; }
// A tag typed by hand or read from an old profile is still valid - resolve it. public ValueTask<VariableDefinition?> ResolveAsync( string localId, CancellationToken cancellationToken = default) => ValueTask.FromResult(Foobar2000Tags.IsValidName(localId) ? VariableDefinition.OnDemand(localId, VariableType.Text) : null);
public async ValueTask<VariableReading> ReadAsync( string localId, CancellationToken cancellationToken = default) => _client.IsConnected ? VariableReading.Of(await _client.GetCustomTagValueAsync(localId, cancellationToken)) : VariableReading.Unavailable;}Catalog rules
Section titled “Catalog rules”- Ids. A catalog id may contain anything except
::, whitespace and control characters, up toMacroDeckId.MaxResourceLocalIdLength-sensor.office_temperatureand GUIDs are fine. Encode names with spaces (Main Camera→Main_Camera) and decode them inResolveAsync; an item with an invalidIdis dropped. Ids are local: the host adds and strips your integration’s prefix. - Paging. Return one page per
DiscoverAsynccall, at mostMaxVariableCatalogPageSizeitems, and put your source’s own cursor inContinuationToken. It is opaque and never persisted. - Hierarchy.
query.ParentIdisnullfor the roots, otherwise the node being opened. SetIsContaineron nodes with children andIsBindable = falseon pure grouping nodes. A flat provider ignoresParentId. - Search. Leave
SupportsSearchoff unless you honorquery.Search; the host then shows no search box rather than filtering a single page. CatalogEntryCount. Return a total only when it is cheap;nullotherwise.ResolveAsyncreturnsnullonly for an invalid id. A resource that is merely gone right now (an unplugged device, a disconnected integration) must still resolve: the binding then shows as unavailable and resumes on its own, whilenullmakes it a broken reference the user has to fix. A plugin that is offline appears as unresolvable until it reconnects, whatever your code returns.
Push instead of poll
Section titled “Push instead of poll”By default the host polls every bound resource with ReadAsync. A provider backed by an event stream
sets SupportsPush => true and publishes instead:
OnAttachedAsync(sink)hands you anIVariableSinkonce (only when bothSupportsCatalogandSupportsPusharetrue).SubscribeAsync(localIds)is called with the complete set of bound ids every time it changes (an empty set means “watch nothing”). Return the current values you already have, or an empty list.- Call
sink.PublishAsyncwith values for ids in the latest set; others are dropped. CallInvalidateCatalogAsyncwhen the set of resources itself changed.
Push applies to the catalog only - eager variables are always polled.
Resource lifetime
Section titled “Resource lifetime”A resource that disappears keeps its binding and variable; it just reads as unavailable and resumes without re-binding once it can be resolved and read again.
Over the plugin protocol
Section titled “Over the plugin protocol”Each eager variable is declared as one capability, like an action. Catalog ids are never declared -
they travel in the operation arguments, which keeps a large catalog under MaxDeclaredCapabilities. See
the WebSocket reference for the
describe/get/set/discover/resolve/subscribe operations and the variable-values host API.
Declare host:variable-values in manifest.json when you use it.
MacroDeckTestHost drives all six operations; see conformance for MDC0311, MDC0314
and MDC0315.
Writing a user variable
Section titled “Writing a user variable”To write a variable the user owns instead of declaring your own, use the user-variable API on
IIntegrationContext: CreateAsync creates one, ApplyAsync changes an existing one. Pass an owner
widget id (from ActionExecutionContext.OwnerWidgetId) to make it local to that widget, where it shadows
a global of the same name; the host refuses an unknown widget id.
ApplyAsync’s Set also works on provider variables that declare Write; NotEditable for the rest.
Add, Toggle and Append are user-variable only, because they compute from the last value the host
saw. Unavailable means the owner accepts writes but could not take this one - retry later.