Skip to content

Tutorial: Task plugin

A task plugin is an entry in the device context menu. The user selects devices, picks the entry, fills a dialog, and OADM runs the work on the server, one task per device. Each task shows in the tasks pane with its steps and log, so one device that fails never marks the others as failed.

In this tutorial you create Set UPnP name: it gives devices a new UPnP name (the name Windows shows in its network list). It has everything a real task needs: a dialog with validation, a read of the current value, a check of the device’s API, named steps, a check after the write, and tests that run without a camera.

You need a checkout of OADM that builds (./manage.sh build works) and the .NET 10 SDK. In the repository root, run:

Terminal window
./manage.sh new task --name "My cool plugin" # PowerShell: .\manage.ps1 new task --name "My cool plugin"

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 UpnpName reads MyCoolPlugin and the id is community.my-cool-plugin (choose your own with --id acme.my-cool-plugin).

  • Directoryplugins/
    • DirectoryOadm.Plugins.MyCoolPlugin/ the server part: does the work on the devices
      • plugin.json
      • Oadm.Plugins.MyCoolPlugin.csproj
      • MyCoolPlugin.cs the payload and the rules both parts share
      • MyCoolPluginTaskPlugin.cs the task
    • DirectoryOadm.Plugins.MyCoolPlugin.Client/ the client part: the dialog
      • Oadm.Plugins.MyCoolPlugin.Client.csproj
      • MyCoolPluginDialog.cs
      • MyCoolPluginViewModel.cs
      • MyCoolPluginWindow.axaml
      • MyCoolPluginWindow.axaml.cs
  • Directorytests/
    • DirectoryOadm.Plugins.MyCoolPlugin.Tests/
      • Fakes.cs a camera and a task context without hardware
      • MyCoolPluginTaskPluginTests.cs
flowchart LR
    M["Context menu<br/>CanRun per device"] --> D["Dialog<br/>QueryAsync reads,<br/>returns the payload"]
    D --> S["Server<br/>one task per device"]
    S --> E["ExecuteAsync<br/>named steps"]
    E --> R(["Done, Done with warnings<br/>or Failed"])
  1. The user selects devices and picks your entry. The entry is greyed out with a reason for devices it cannot run on.
  2. Your dialog opens on the user’s computer. It may read the current state from a device, then returns a payload: a small JSON text with what the user chose.
  3. The server creates one task per device and runs your ExecuteAsync for each, up to 16 at a time. It hands you a VAPIX client that is already logged in to the device; the password never reaches your code.
  4. Every request and every wait is a step with a name. The user sees the steps live, and which one failed.
  5. Returning means Done, an exception means Failed with its message, a warning means Done with warnings.

The server part never shows UI, the client part never talks to a device.

plugin.json tells OADM what the folder holds. The id is the plugin’s key everywhere (folder, settings, permissions) and never changes. displayName and description are its row on the Plugins page.

plugin.json
{
"id": "acme.upnp-name",
"version": "1.0.0",
"minSdkVersion": "0.1.0",
"displayName": "Set UPnP name",
"description": "Sets the name devices show in the network list of Windows."
}

Optional fields: "enabledByDefault": false keeps the plugin off until an administrator turns it on.

The server project compiles against the SDK but never copies it: OADM itself 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.

Oadm.Plugins.MyCoolPlugin.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Oadm.Samples.UpnpName.Server</AssemblyName>
<RootNamespace>Oadm.Samples.UpnpName</RootNamespace>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OadmPluginId>acme.upnp-name</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>

The client project does the same with the client SDK and Avalonia (the UI framework), and references the server project to share its payload record.

Oadm.Plugins.MyCoolPlugin.Client.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Oadm.Samples.UpnpName.Client</AssemblyName>
<RootNamespace>Oadm.Samples.UpnpName.Client</RootNamespace>
<EnableDynamicLoading>true</EnableDynamicLoading>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<OadmPluginId>acme.upnp-name</OadmPluginId>
<OadmPluginOutputDir>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', '..', 'artifacts', 'plugins', '$(OadmPluginId)'))</OadmPluginOutputDir>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.4" ExcludeAssets="runtime" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" ExcludeAssets="runtime" />
<ProjectReference Include="..\..\src\Oadm.Sdk.Client\Oadm.Sdk.Client.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\src\Oadm.Sdk\Oadm.Sdk.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\Oadm.Samples.UpnpName\Oadm.Samples.UpnpName.csproj" />
</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>

The dialog and the task must agree on the payload and on what a valid name is. Writing that once, in the server project, keeps them from drifting apart.

MyCoolPlugin.cs
using System.Text.Json;
namespace Oadm.Samples.UpnpName;
/// <summary>What the dialog sends and the query answers. Shared by the server and the client part.</summary>
public sealed record UpnpNamePayload(string Name);
public static class UpnpName
{
public const string PluginId = "acme.upnp-name";
public const string Parameter = "Network.UPnP.FriendlyName";
public const int MaxLength = 64;
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
/// <summary>Why the name cannot be used, or null. The dialog and the task use the same rule.</summary>
public static string? Problem(string? name)
{
var text = name?.Trim() ?? "";
if (text.Length == 0)
{
return "Enter a name.";
}
if (text.Length > MaxLength)
{
return $"Use at most {MaxLength} characters.";
}
return text.Any(char.IsControl) ? "The name cannot contain control characters." : null;
}
}

This is the heart of the plugin. Read it from top to bottom:

  • DisplayName, Group, IconKey: the entry in the context menu, here Network > Set UPnP name.
  • CanRun decides from what OADM already knows (status, the device’s API list) whether the entry is enabled. It must be fast: it runs for every selected device while the menu opens. NotSupportedReason is the tooltip of a greyed entry.
  • GetTaskName is the name in the tasks pane: what this task does (“Set UPnP name Entrance”), never a password.
  • ExecuteAsync runs once per device:
    1. it checks the input before anything is sent;
    2. PlanSteps shows all steps as pending;
    3. each StepAsync / BeginStep is one named step; a step that does not apply is skipped with a reason;
    4. before the first write it asks the device for its API list again and stops with “Nothing was changed” when the API is missing;
    5. it reads the value back after writing and warns when the device did not take it.
  • QueryAsync answers the dialog. It only reads; changing a device here is not allowed.
MyCoolPluginTaskPlugin.cs
using System.Text;
using System.Text.Json;
using Oadm.Sdk.Devices;
using Oadm.Sdk.Plugins;
using Oadm.Sdk.Vapix;
namespace Oadm.Samples.UpnpName;
public sealed class UpnpNameTaskPlugin : ITaskPlugin, ITaskPluginQuery
{
private const string StepCheck = "Check compatibility";
private const string StepRead = "Read the current name";
private const string StepSet = "Set the name";
private const string StepVerify = "Verify the name";
public string Id => UpnpName.PluginId;
public string DisplayName => "Set UPnP name";
public string? IconKey => "edit";
public string Group => TaskGroups.Network;
public bool ShowInToolbar => false;
public bool RequiresDialog => true;
// Cached data only: the status and the API list of the last full refresh.
public bool CanRun(IDeviceInfo device) =>
device.Status is DeviceStatus.Ok or DeviceStatus.Unknown && device.Apis.Supports("param-cgi", "1.0");
public string? NotSupportedReason(IDeviceInfo device) =>
TaskSupportReasons.ForStatus(device.Status) ?? TaskSupportReasons.NeedsApi("the parameter API", device);
public string GetTaskName(string? payloadJson)
{
var payload = Read(payloadJson);
return payload is null ? DisplayName : "Set UPnP name " + payload.Name.Trim();
}
public async Task ExecuteAsync(ITaskExecutionContext ctx, IDeviceInfo device, string? payloadJson, CancellationToken ct)
{
// Validate everything before the first request.
var name = Read(payloadJson)?.Name.Trim();
if (UpnpName.Problem(name) is { } problem)
{
throw new ArgumentException(problem + " Nothing was changed.");
}
ctx.PlanSteps(StepCheck, StepRead, StepSet, StepVerify);
await ctx.StepAsync(StepCheck, async _ =>
(await ctx.Vapix.GetApiListAsync(ct)).Require("param-cgi", "1.0"));
var current = await ctx.StepAsync(StepRead, async step =>
{
var value = await ReadNameAsync(ctx.Vapix, ct);
step.Complete(value.Length == 0 ? "No name" : value);
return value;
});
if (current == name)
{
ctx.SkipStep(StepSet, "The device already has this name");
ctx.SkipStep(StepVerify, "Nothing was changed");
return;
}
await ctx.StepAsync(StepSet, _ => WriteNameAsync(ctx.Vapix, name!, ct));
using var verify = ctx.BeginStep(StepVerify);
var after = await ReadNameAsync(ctx.Vapix, ct);
if (after == name)
{
verify.Complete(after);
}
else
{
verify.Warn($"The device reports \"{after}\" after the change.");
}
}
// Read-only: the dialog shows the current name of the first selected device.
public async Task<string?> QueryAsync(ITaskQueryContext ctx, IDeviceInfo device, string method, string? payloadJson, CancellationToken ct) =>
method switch
{
"current" => JsonSerializer.Serialize(new UpnpNamePayload(await ReadNameAsync(ctx.Vapix, ct)), UpnpName.Json),
_ => throw new ArgumentException("Unknown query " + method),
};
private static async Task<string> ReadNameAsync(IVapixClient vapix, CancellationToken ct)
{
var values = await vapix.ListParametersAsync([UpnpName.Parameter], ct);
return values.TryGetValue(UpnpName.Parameter, out var value) ? value : "";
}
private static async Task WriteNameAsync(IVapixClient vapix, string name, CancellationToken ct)
{
// Values go in the POST body, never in the URL.
var body = "action=update&" + Uri.EscapeDataString(UpnpName.Parameter) + "=" + Uri.EscapeDataString(name);
using var request = new HttpRequestMessage(HttpMethod.Post, "/axis-cgi/param.cgi")
{
Content = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"),
};
using var response = await vapix.SendAsync(request, ct);
var text = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode || !text.TrimStart().StartsWith("OK", StringComparison.Ordinal))
{
throw new InvalidOperationException("The device refused the name: " + text.Trim());
}
}
private static UpnpNamePayload? Read(string? json)
{
try
{
return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize<UpnpNamePayload>(json, UpnpName.Json);
}
catch (JsonException)
{
return null;
}
}
}

The client loader finds the class that implements ITaskPluginDialog with your plugin id. ShowAsync opens the window, prefills it through your query (the dialog works without it too) and returns the payload, or null when the user cancels.

MyCoolPluginDialog.cs
using System.Text.Json;
using Avalonia.Controls;
using Oadm.Sdk.Client;
using Oadm.Sdk.Devices;
namespace Oadm.Samples.UpnpName.Client;
/// <summary>Found by the client loader: opens before the task runs and returns the payload (null = cancelled).</summary>
public sealed class UpnpNameDialog : ITaskPluginDialog
{
public string PluginId => UpnpName.PluginId;
public async Task<string?> ShowAsync(ITaskDialogContext ctx, IReadOnlyList<IDeviceInfo> devices, Window owner)
{
var model = new UpnpNameViewModel(devices.Count);
var window = new UpnpNameWindow { DataContext = model };
// Prefill with the name of the first device; the dialog works without it.
using var cts = new CancellationTokenSource();
_ = model.LoadAsync(token => ctx.QueryAsync(devices[0].Id, "current", null, token), cts.Token);
var ok = await window.ShowDialog<bool>(owner);
await cts.CancelAsync();
return ok ? JsonSerializer.Serialize(new UpnpNamePayload(model.Name.Trim()), UpnpName.Json) : null;
}
}

The view model holds the form. It derives from ValidatingViewModel: each rule returns an error text or null, the error shows directly below the field once the user typed or left it, and OK stays disabled while there is one.

MyCoolPluginViewModel.cs
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Oadm.Sdk.Client.Validation;
namespace Oadm.Samples.UpnpName.Client;
public sealed partial class UpnpNameViewModel : ValidatingViewModel
{
public UpnpNameViewModel(int deviceCount)
{
Intro = deviceCount == 1 ? "The name the device shows in the network list." : $"The name {deviceCount} devices show in the network list.";
Validation.Rule(nameof(Name), () => UpnpName.Problem(Name));
Validation.Validate();
}
public string Intro { get; }
[ObservableProperty]
public partial string Name { get; set; } = "";
/// <summary>The window closes with true (OK) or false (Cancel).</summary>
public event Action<bool>? CloseRequested;
public async Task LoadAsync(Func<CancellationToken, Task<string?>> query, CancellationToken ct)
{
try
{
var json = await query(ct);
if (Name.Length == 0 && json is not null && JsonSerializer.Deserialize<UpnpNamePayload>(json, UpnpName.Json) is { } current)
{
Name = current.Name;
Validation.Reset(nameof(Name)); // a prefilled value shows no error yet
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
// No prefill: the user types the name.
}
}
[RelayCommand(CanExecute = nameof(IsFormValid))]
private void Apply() => CloseRequested?.Invoke(true);
[RelayCommand]
private void Cancel() => CloseRequested?.Invoke(false);
protected override void OnValidationChanged() => ApplyCommand.NotifyCanExecuteChanged();
}

The window uses the shared parts of every OADM dialog: the title bar with its X, a form field with the label left and the error below the input, and the footer with Cancel and one accent button. Use them and your dialog looks like the rest of the app in dark and light, without any colors or sizes of its own.

MyCoolPluginWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:Oadm.Sdk.Client.Controls"
xmlns:vm="using:Oadm.Samples.UpnpName.Client"
x:Class="Oadm.Samples.UpnpName.Client.UpnpNameWindow"
x:DataType="vm:UpnpNameViewModel"
Title="Set UPnP name"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
WindowDecorations="BorderOnly">
<Grid RowDefinitions="Auto,Auto,Auto">
<ui:DialogTitleBar Grid.Row="0" Text="Set UPnP name" />
<Border Grid.Row="1" Classes="dialogBody">
<StackPanel Classes="form">
<TextBlock Classes="secondary" Text="{Binding Intro}" TextWrapping="Wrap" />
<ui:FormField Label="Name">
<TextBox Text="{Binding Name}" />
</ui:FormField>
</StackPanel>
</Border>
<ui:DialogFooter Grid.Row="2" CancelCommand="{Binding CancelCommand}">
<Button Classes="primary" Content="OK" Command="{Binding ApplyCommand}" IsDefault="True"
ToolTip.Tip="{Binding FormError}" ToolTip.ShowOnDisabled="True" />
</ui:DialogFooter>
</Grid>
</Window>
MyCoolPluginWindow.axaml.cs
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Oadm.Samples.UpnpName.Client;
public sealed partial class UpnpNameWindow : Window
{
public UpnpNameWindow() => AvaloniaXamlLoader.Load(this);
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is UpnpNameViewModel model)
{
model.CloseRequested += ok => Close(ok);
}
}
}

The tests need no camera. FakeCamera answers the two requests the task sends, FakeTaskContext records the steps, and StepRun ends the task the way the server does (it adds the final step Completed on success).

Fakes.cs
using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Oadm.Sdk.Devices;
using Oadm.Sdk.Plugins;
using Oadm.Sdk.Vapix;
namespace Oadm.Samples.UpnpName.Tests;
/// <summary>A camera that knows the param.cgi list and update calls.</summary>
internal sealed class FakeCamera : IVapixClient
{
public Dictionary<string, string> Parameters { get; } = new() { [UpnpName.Parameter] = "AXIS P3265-V - B8A44F631339" };
public IReadOnlyList<DeviceApi> Apis { get; set; } = [new("param-cgi", "1.0")];
public int Writes { get; private set; }
public Uri BaseAddress { get; } = new("https://192.0.2.10/");
public Task<IReadOnlyList<DeviceApi>> GetApiListAsync(CancellationToken ct) => Task.FromResult(Apis);
public Task<IReadOnlyDictionary<string, string>> ListParametersAsync(IEnumerable<string> groups, CancellationToken ct) =>
Task.FromResult<IReadOnlyDictionary<string, string>>(
Parameters.Where(p => groups.Any(g => p.Key.StartsWith(g, StringComparison.Ordinal))).ToDictionary());
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var body = await request.Content!.ReadAsStringAsync(ct);
foreach (var pair in body.Split('&').Skip(1)) // after "action=update"
{
var parts = pair.Split('=', 2);
Parameters[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]);
}
Writes++;
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("OK") };
}
public Task<BasicDeviceInfo> GetBasicDeviceInfoAsync(CancellationToken ct) => throw new NotSupportedException();
public Task RestartAsync(CancellationToken ct) => throw new NotSupportedException();
}
internal sealed record FakeDevice(DeviceStatus Status = DeviceStatus.Ok, IReadOnlyList<DeviceApi>? ApiList = null) : IDeviceInfo
{
public Guid Id { get; } = Guid.NewGuid();
public string Serial => "B8A44F631339";
public string Address => "192.0.2.10";
public string? HostName => null;
public string? Model => "P3265-V";
public string? FirmwareVersion => "12.11.77";
public DeviceCategory Category => DeviceCategory.Camera;
public bool HasVideo => true;
public IReadOnlyList<DeviceApi> Apis => ApiList ?? [new("param-cgi", "1.0")];
}
/// <summary>The task context of the server, recording steps and warnings.</summary>
internal sealed class FakeTaskContext(IVapixClient vapix) : ITaskExecutionContext
{
public TaskStepList Steps { get; } = new();
public List<string> Warnings { get; } = [];
public Guid TaskId { get; } = Guid.NewGuid();
public IVapixClient Vapix => vapix;
public ILogger Logger => NullLogger.Instance;
public ICorePlugin? Owner => null;
public IUploadedFiles Files => throw new NotSupportedException();
public void PlanSteps(params string[] names) => Steps.Plan(names);
public ITaskStep BeginStep(string name) => Steps.Begin(name);
public void ReportProgress(int percent, string? message = null) { }
public void ReportWarning(string message) => Warnings.Add(message);
public void Log(TaskLogLevel level, string message) { }
}

Test the normal run step by step, a value that is already set, a device without the API, invalid input, and what the menu shows:

MyCoolPluginTaskPluginTests.cs
using Oadm.Sdk.Devices;
using Oadm.Sdk.Vapix;
using Oadm.Tests.Shared;
namespace Oadm.Samples.UpnpName.Tests;
public sealed class UpnpNameTaskPluginTests
{
private readonly UpnpNameTaskPlugin _plugin = new();
[Fact]
public async Task Sets_and_verifies_the_name()
{
var camera = new FakeCamera();
var ctx = new FakeTaskContext(camera);
await StepRun.RunAsync(ctx.Steps, () => _plugin.ExecuteAsync(ctx, new FakeDevice(), """{"name":"Entrance"}""", CancellationToken.None));
Assert.Equal("Entrance", camera.Parameters[UpnpName.Parameter]);
Assert.Equal(
["Check compatibility: Done", "Read the current name: Done", "Set the name: Done", "Verify the name: Done", "Completed: Done"],
StepRun.Lines(ctx.Steps));
}
[Fact]
public async Task The_same_name_is_not_written_again()
{
var camera = new FakeCamera();
camera.Parameters[UpnpName.Parameter] = "Entrance";
var ctx = new FakeTaskContext(camera);
await StepRun.RunAsync(ctx.Steps, () => _plugin.ExecuteAsync(ctx, new FakeDevice(), """{"name":"Entrance"}""", CancellationToken.None));
Assert.Equal(0, camera.Writes);
Assert.Equal("Set the name: Skipped", StepRun.Lines(ctx.Steps)[2]);
}
[Fact]
public async Task A_device_without_the_API_is_not_changed()
{
var camera = new FakeCamera { Apis = [] };
var ctx = new FakeTaskContext(camera);
await Assert.ThrowsAsync<DeviceNotCompatibleException>(() =>
StepRun.RunAsync(ctx.Steps, () => _plugin.ExecuteAsync(ctx, new FakeDevice(), """{"name":"Entrance"}""", CancellationToken.None)));
Assert.Equal(0, camera.Writes);
Assert.Equal("Check compatibility: Failed", StepRun.Lines(ctx.Steps)[0]);
}
[Fact]
public async Task An_empty_name_fails_before_any_request()
{
var camera = new FakeCamera();
var ctx = new FakeTaskContext(camera);
var error = await Assert.ThrowsAsync<ArgumentException>(() =>
_plugin.ExecuteAsync(ctx, new FakeDevice(), """{"name":" "}""", CancellationToken.None));
Assert.Contains("Nothing was changed", error.Message, StringComparison.Ordinal);
Assert.Equal(0, camera.Writes);
}
[Fact]
public void The_menu_entry_says_why_it_cannot_run()
{
Assert.True(_plugin.CanRun(new FakeDevice()));
Assert.False(_plugin.CanRun(new FakeDevice(DeviceStatus.Unreachable)));
Assert.Equal("The device does not answer", _plugin.NotSupportedReason(new FakeDevice(DeviceStatus.Unreachable)));
Assert.False(_plugin.CanRun(new FakeDevice(ApiList: [])));
}
[Fact]
public void The_task_name_says_what_happens() =>
Assert.Equal("Set UPnP name Entrance", _plugin.GetTaskName("""{"name":"Entrance"}"""));
}
Terminal window
dotnet test tests/Oadm.Plugins.MyCoolPlugin.Tests
./manage.sh run dev

run dev builds everything, starts the server and opens the client. Select a device, right-click, and open Network > My cool plugin. The tasks pane shows one task per device; Details shows its steps and log.

Change one thing at a time and keep the tests green:

  1. Name and place: DisplayName (at most 32 characters, no “…”), Group, IconKey.
  2. Which devices: CanRun and NotSupportedReason. Check the API your task uses with device.Apis.Supports("api-id", "1.0").
  3. What the user chooses: the payload record, the dialog’s fields and their rules.
  4. What happens on the device: the steps in ExecuteAsync, one named step per request. Name steps as short commands with the article: “Read the users”, “Wait for the device to come back”.
  5. The task name: GetTaskName says exactly what one task does.
  6. The tests: one per path through ExecuteAsync.

To use it on an installed OADM, copy the folder artifacts/plugins/<your id>/ into the plugins folder next to the OADM server and next to the client, then restart both. 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 on which device and AXIS OS you tried it.