Tutorial: Event rule action
The event rules of OADM say “when this happens, then do that”. An action is a “then”: send an email, switch a relay, run a VAPIX command. Your plugin can add one. The rule dialog draws its settings form from a short list of fields, so an action needs no UI code at all.
In this tutorial you create Set virtual input: it turns a virtual input of the chosen devices on while the rule is on, and off again when the rule turns off. The devices’ own event rules can then react to it, for example start a recording.
1. Create it
Section titled “1. Create it”You need a checkout of OADM that builds (./manage.sh build works) and the .NET 10 SDK. In the repository root, run:
./manage.sh new action --name "My cool action" # PowerShell: .\manage.ps1 new action --name "My cool action"This copies the tutorial’s plugin under your name and adds it to the solution. It builds, its tests pass, and it runs.
The code below is the original; in your copy every VirtualInput reads MyCoolAction.
Directoryplugins/
DirectoryOadm.Plugins.MyCoolAction/ server only: actions need no client part
- plugin.json
- Oadm.Plugins.MyCoolAction.csproj
- MyCoolActionAction.cs
Directorytests/
DirectoryOadm.Plugins.MyCoolAction.Tests/
- MyCoolActionActionTests.cs
2. How an action runs
Section titled “2. How an action runs”flowchart LR
F["A one-shot event fires"] --> A1["ExecuteAsync<br/>Edge = Fired"]
N["The rule turns on"] --> A2["ExecuteAsync<br/>Edge = TurnedOn"]
O["The rule turns off"] --> A3["ExecuteAsync<br/>Edge = TurnedOff<br/>(only with SupportsTurnOff)"]
- An administrator creates a rule: When an event (an input turns on, a device stops answering, a schedule), Then your action with its settings and its target devices.
- The rule dialog shows your fields. OADM checks the values (required, ranges, lengths) in the dialog and again on the server, and stores them with the rule. Password fields are stored encrypted and never sent back to a client.
- When the rule fires (one-shot events) or turns on, OADM calls
ExecuteAsync, once per target device when your action works on devices. When the rule turns off, it calls it again with that edge, if your action supports it. - Each run gets 30 seconds. A run that throws is logged as failed with its message in the event log; there is no retry. At most 16 runs go at once for all rules.
3. The plugin description
Section titled “3. The plugin description”The id is the plugin’s key everywhere and never changes. displayName and description are its row on the
Plugins page.
{ "id": "acme.virtual-input", "version": "1.0.0", "minSdkVersion": "0.1.0", "displayName": "Set virtual input", "description": "An event rule action that turns a virtual input of the devices on and off."}4. The project file
Section titled “4. The project file”Only a server part. It compiles against the SDK but never copies it: OADM provides it at runtime. After each build the
output is copied to artifacts/plugins/<id>/, where OADM loads plugins from when it runs from your checkout.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <AssemblyName>Oadm.Samples.VirtualInput.Server</AssemblyName> <RootNamespace>Oadm.Samples.VirtualInput</RootNamespace> <EnableDynamicLoading>true</EnableDynamicLoading> <OadmPluginId>acme.virtual-input</OadmPluginId> <OadmPluginOutputDir>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', '..', 'artifacts', 'plugins', '$(OadmPluginId)'))</OadmPluginOutputDir> </PropertyGroup>
<ItemGroup> <ProjectReference Include="..\..\src\Oadm.Sdk\Oadm.Sdk.csproj"> <Private>false</Private> <ExcludeAssets>runtime</ExcludeAssets> </ProjectReference> </ItemGroup>
<ItemGroup> <None Update="plugin.json" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup>
<Target Name="OadmDeployPlugin" AfterTargets="Build" Condition="'$(OadmSkipPluginDeploy)' != 'true'"> <ItemGroup> <_OadmPluginFiles Include="$(OutDir)**\*" /> </ItemGroup> <Copy SourceFiles="@(_OadmPluginFiles)" DestinationFiles="@(_OadmPluginFiles->'$(OadmPluginOutputDir)%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" /> </Target></Project>5. The action
Section titled “5. The action”Id,DisplayName,Group,Description: the action in the “Then” list of the rule dialog.NeedsDevice: true means the rule chooses target devices and your action runs once per device, withctx.Vapixalready logged in to it. False means it runs once per rule edge, like sending an email.SupportsTurnOff: the action also runs when the rule turns off (invocation.EdgeisTurnedOff).Fields: the settings form. Kinds:Text,Multiline,Number,Bool,Choice,DynamicChoice(choices yourListChoicesAsynclists while the rule is edited),Password,Code(a body in a code editor) andKeyValues(name and value rows). Each can have a default, a hint and a range.Describe: the short text in the rules list (“Virtual input 64”).ExecuteAsync: reads the settings withinvocation.GetInt,Get,GetBool, sends the request, and writes one line to the event log withctx.Log.ctx.Text(...)fills placeholders such as{rule},{event},{device},{address}and every value of the event ({port});UsesPlaceholdersmakes the dialog list them.
using System.Globalization;using System.Text.Json;
using Oadm.Sdk.Actions;
namespace Oadm.Samples.VirtualInput;
/// <summary>/// An event rule action: turns a virtual input of the target devices on when the rule turns on (or fires), and off again/// when it turns off. The devices' own event rules can react to the input. The rule dialog draws the form from/// <see cref="Fields"/>; no client part is needed./// </summary>public sealed class VirtualInputAction : IActionPlugin{ public const string ActionId = "acme.virtual-input";
public string Id => ActionId; public string DisplayName => "Set virtual input"; public string? IconKey => "toggle"; public string Group => "Devices"; public string? Description => "Turns a virtual input of the devices on while the rule is on.";
public bool NeedsDevice => true; // runs once per target device, with ctx.Vapix logged in to it public bool SupportsTurnOff => true; // also runs when the rule turns off public bool UsesPlaceholders => true; // the rule dialog lists the placeholders for the log text
public IReadOnlyList<ActionField> Fields { get; } = [ ActionField.Number("port", "Virtual input", min: 1, max: 64, defaultValue: 64), ActionField.Text("note", "Log text", defaultValue: "{rule} on {address}") with { Hint = "Written to the event log" }, ];
/// <summary>The short text of the rules list.</summary> public string Describe(JsonElement settings) => "Virtual input " + (settings.TryGetProperty("port", out var port) ? port.ToString() : "64");
public async Task ExecuteAsync(IActionContext ctx, ActionInvocation invocation, CancellationToken ct) { var port = invocation.GetInt("port", 64); var action = invocation.Edge == ActionEdge.TurnedOff ? "deactivate" : "activate"; using var request = new HttpRequestMessage( HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/axis-cgi/virtualinput/{action}.cgi?schemaversion=1&port={port}")); using var response = await ctx.Vapix!.SendAsync(request, ct); if (!response.IsSuccessStatusCode) { // A failed run: the message goes into the event log. throw new InvalidOperationException($"The device refused virtual input {port}: HTTP {(int)response.StatusCode}."); }
ctx.Log(ctx.Text(invocation.Get("note")) + (action == "activate" ? ": on" : ": off")); }}6. The tests
Section titled “6. The tests”The tests need no camera and no rules: a fake camera records the requests, a fake context records the event log lines
and fills {rule}. Each test builds the invocation a rule would send.
using System.Net;using System.Text.Json;
using Microsoft.Extensions.Logging;using Microsoft.Extensions.Logging.Abstractions;
using Oadm.Sdk.Actions;using Oadm.Sdk.Plugins;using Oadm.Sdk.Vapix;
namespace Oadm.Samples.VirtualInput.Tests;
public sealed class VirtualInputActionTests{ private readonly VirtualInputAction _action = new();
[Fact] public async Task Turning_on_activates_the_input_and_turning_off_deactivates_it() { var camera = new FakeCamera(); var ctx = new FakeActionContext(camera);
await _action.ExecuteAsync(ctx, Run(ActionEdge.TurnedOn, """{"port":12,"note":"{rule}"}"""), CancellationToken.None); await _action.ExecuteAsync(ctx, Run(ActionEdge.TurnedOff, """{"port":12,"note":"{rule}"}"""), CancellationToken.None);
Assert.Equal( ["/axis-cgi/virtualinput/activate.cgi?schemaversion=1&port=12", "/axis-cgi/virtualinput/deactivate.cgi?schemaversion=1&port=12"], camera.Requests); Assert.Equal(["Door open: on", "Door open: off"], ctx.Lines); }
[Fact] public async Task A_refused_request_fails_the_run() { var ctx = new FakeActionContext(new FakeCamera { Status = HttpStatusCode.Forbidden });
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => _action.ExecuteAsync(ctx, Run(ActionEdge.Fired, """{"port":64}"""), CancellationToken.None));
Assert.Contains("HTTP 403", error.Message, StringComparison.Ordinal); }
[Fact] public void The_rules_list_names_the_input() { using var settings = JsonDocument.Parse("""{"port":7}"""); Assert.Equal("Virtual input 7", _action.Describe(settings.RootElement)); }
private static ActionInvocation Run(ActionEdge edge, string settingsJson) => new( Guid.NewGuid(), "Door open", edge, JsonDocument.Parse(settingsJson).RootElement, null, new ActionTrigger("input", "Input 1 active", null, null, null, edge != ActionEdge.TurnedOff, DateTimeOffset.UtcNow, new Dictionary<string, string>()));
private sealed class FakeCamera : IVapixClient { public List<string> Requests { get; } = []; public HttpStatusCode Status { get; init; } = HttpStatusCode.OK; public Uri BaseAddress { get; } = new("https://192.0.2.10/");
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) { Requests.Add(request.RequestUri!.OriginalString); return Task.FromResult(new HttpResponseMessage(Status) { Content = new StringContent("OK") }); }
public Task<BasicDeviceInfo> GetBasicDeviceInfoAsync(CancellationToken ct) => throw new NotSupportedException(); public Task<IReadOnlyDictionary<string, string>> ListParametersAsync(IEnumerable<string> groups, CancellationToken ct) => throw new NotSupportedException(); public Task RestartAsync(CancellationToken ct) => throw new NotSupportedException(); public Task<IReadOnlyList<DeviceApi>> GetApiListAsync(CancellationToken ct) => throw new NotSupportedException(); }
/// <summary>The server's context of one run: the device's client, the event log lines, the placeholders.</summary> private sealed class FakeActionContext(IVapixClient vapix) : IActionContext { public List<string> Lines { get; } = []; public IVapixClient? Vapix => vapix; public ILogger Logger => NullLogger.Instance; public ICorePlugin? Owner => null; public IPluginSettings Settings => throw new NotSupportedException(); public void Log(string message) => Lines.Add(message); public string Text(string? text) => (text ?? "").Replace("{rule}", "Door open", StringComparison.Ordinal); }}7. Run it
Section titled “7. Run it”dotnet test tests/Oadm.Plugins.MyCoolAction.Tests./manage.sh run devOpen Event rules, click New rule: in step Then, choose My cool action, set the virtual input and the target devices, and save. When the rule turns on, the event log shows your line ending in “: on”; when it turns off, “: off”.
8. Make it yours
Section titled “8. Make it yours”- Name and place:
DisplayName,Group,Description,IconKey. - Devices or not:
NeedsDevice, andSupportsTurnOffwhen undoing makes sense. - The settings: the
Fields, andValidate(settings, statefulRule)for checks across fields (it returns errors by field key; they show below the fields in the dialog). - The work:
ExecuteAsync. Throw with a clear message when something fails; the message is what the event log shows. Never write a password to the log. - More than 30 seconds: return a longer time from
MaxRunTime(settings). - The tests: one per edge and per answer of the device or service.
9. Share it
Section titled “9. Share it”To use it on an installed OADM, copy the folder artifacts/plugins/<your id>/ into the plugins folder next to the
OADM server, then restart it (an action has no client part). To share it with everyone, open a pull request on GitHub
with the plugin, its tests and a README.md in its folder that says what it does and what you tried it with.