Skip to content

Tutorial: Plugin page

A plugin page is an entry in the navigation rail with a page of its own, like the NTP server or Edge storage. Behind it runs a core plugin: a service inside the OADM server that keeps state, talks to the devices and answers the page.

In this tutorial you create Firmware overview: a page with the AXIS OS version of every device. Refresh reads all devices on the server, 16 at a time; the table fills in as soon as the read is done, on every client that shows the page.

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 page --name "My cool page" # PowerShell: .\manage.ps1 new page --name "My cool page"

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 FirmwareOverview reads MyCoolPage.

  • Directoryplugins/
    • DirectoryOadm.Plugins.MyCoolPage/ the server part: the service
      • plugin.json
      • Oadm.Plugins.MyCoolPage.csproj
      • FirmwareModels.cs the rows, the state and the method names both parts share
      • MyCoolPagePlugin.cs the service
    • DirectoryOadm.Plugins.MyCoolPage.Client/ the client part: the page
      • Oadm.Plugins.MyCoolPage.Client.csproj
      • MyCoolPagePage.cs the rail entry
      • MyCoolPageViewModel.cs
      • MyCoolPageView.axaml
      • MyCoolPageView.axaml.cs
  • Directorytests/
    • DirectoryOadm.Plugins.MyCoolPage.Tests/
      • Fakes.cs devices, cameras and both contexts without hardware
      • MyCoolPageTests.cs
sequenceDiagram
    participant Page as Page (client)
    participant Plugin as Core plugin (server)
    participant Devices
    Page->>Plugin: WatchEventsAsync() (subscribe first)
    Page->>Plugin: InvokeAsync("getState")
    Plugin-->>Page: the whole state as JSON
    Page->>Plugin: InvokeAsync("refresh")
    Plugin-->>Page: returns at once
    par in the background, 16 devices at a time
        Plugin->>Devices: basicdeviceinfo
        Devices-->>Plugin: AXIS OS version
    end
    Plugin-->>Page: Events.Publish("state", json), to every page that watches
  • The page calls methods of the server part by name, with JSON in and out (InvokeAsync).
  • The server part pushes events to every page that shows it (Events.Publish). Several users, or several windows of one user, all see the same table.
  • Events can be lost when the connection drops. So the page always subscribes, reads the whole state, then applies events, and starts over after a drop. A version number in the state lets the page ignore anything older than what it shows.
  • Long work never blocks a method: the method starts it and returns; the result arrives as an event.

The id is the plugin’s key everywhere and never changes. displayName and description are its row on the Plugins page, where administrators turn it on and off.

plugin.json
{
"id": "acme.firmware-overview",
"version": "1.0.0",
"minSdkVersion": "0.1.0",
"displayName": "Firmware overview",
"description": "The AXIS OS version of every device on one page."
}

Add "enabledByDefault": false when the plugin works in the background or is not for everyone: it then stays off until an administrator turns it on.

The server project 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.

Oadm.Plugins.MyCoolPage.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Oadm.Samples.FirmwareOverview.Server</AssemblyName>
<RootNamespace>Oadm.Samples.FirmwareOverview</RootNamespace>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OadmPluginId>acme.firmware-overview</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 adds Avalonia and its data grid, again without copying them, and references the server project to share the models.

Oadm.Plugins.MyCoolPage.Client.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Oadm.Samples.FirmwareOverview.Client</AssemblyName>
<RootNamespace>Oadm.Samples.FirmwareOverview.Client</RootNamespace>
<EnableDynamicLoading>true</EnableDynamicLoading>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<OadmPluginId>acme.firmware-overview</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" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.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.FirmwareOverview\Oadm.Samples.FirmwareOverview.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 page and the service agree on three things: the method names, the event topic and the JSON of the state. Keeping them in one file of the server project means a typo cannot break the page.

FirmwareModels.cs
using System.Text.Json;
namespace Oadm.Samples.FirmwareOverview;
/// <summary>One row of the page: a device and its AXIS OS, or why it could not be read.</summary>
public sealed record FirmwareRow(Guid DeviceId, string Address, string Model, string? Version, string? Error);
/// <summary>Everything the page shows. Version goes up with every change, so the page can ignore older states.</summary>
public sealed record FirmwareState(int Version, bool Reading, IReadOnlyList<FirmwareRow> Rows);
/// <summary>The contract between the server part and the page: method names, the event topic and the JSON format.</summary>
public static class FirmwareOverview
{
public const string PluginId = "acme.firmware-overview";
public const string GetState = "getState";
public const string Refresh = "refresh";
public const string StateTopic = "state";
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
}
  • Id, DisplayName, IconKey, Group: the rail entry. Group places it under a heading of the rail (here Maintenance).
  • StartAsync runs when the server starts and when an administrator turns the plugin on. Keep the context and return at once; never wait for devices here.
  • InvokeAsync is the page’s backend. Throw ArgumentException for bad input and InvalidOperationException for a wrong state: the page gets the message.
  • ReadAllAsync reads every device with at most 16 at a time and 5 seconds each. A site can have thousands of devices; never read them one by one, and never all at once.
  • DeviceMessages.Refusal skips devices OADM must not contact: a changed certificate, a rejected password, no password yet, or not answering. The row says why, in the same words as the rest of OADM.
  • ctx.Vapix.CreateAsync gives a client that is already logged in to the device. The server keeps it; never dispose it.
  • Publish stores the new state with the next version and sends it to every page.
MyCoolPagePlugin.cs
using System.Collections.Concurrent;
using System.Text.Json;
using Oadm.Sdk.Devices;
using Oadm.Sdk.Plugins;
namespace Oadm.Samples.FirmwareOverview;
/// <summary>
/// The server part: keeps the table, reads the devices when the page asks, and pushes every new state to the pages.
/// </summary>
public sealed class FirmwareOverviewPlugin : ICorePlugin, IDisposable
{
private readonly Lock _gate = new();
private ICorePluginContext? _ctx;
private CancellationTokenSource _stop = new();
private FirmwareState _state = new(0, false, []);
public string Id => FirmwareOverview.PluginId;
public string DisplayName => "Firmware overview";
public string? IconKey => "firmware";
public CorePluginGroup Group => CorePluginGroup.Maintenance;
public IReadOnlyList<ITaskPlugin> TaskPlugins => [];
public Task StartAsync(ICorePluginContext ctx, CancellationToken ct)
{
_ctx = ctx;
_stop = new CancellationTokenSource();
return Task.CompletedTask; // nothing to start: the page asks for a read
}
public async Task StopAsync(CancellationToken ct) => await _stop.CancelAsync();
public Task<string?> InvokeAsync(string method, string? payloadJson, CancellationToken ct)
{
switch (method)
{
case FirmwareOverview.GetState:
return Task.FromResult<string?>(Serialize(Current));
case FirmwareOverview.Refresh:
if (!TryBeginRead())
{
throw new InvalidOperationException("The devices are being read already.");
}
// The read takes a while: it runs in the background and the page follows it through events.
_ = Task.Run(() => ReadAllAsync(_stop.Token), CancellationToken.None);
return Task.FromResult<string?>(null);
default:
throw new ArgumentException("Unknown method " + method);
}
}
public void Dispose() => _stop.Dispose();
/// <summary>Reads every device, 16 at a time, 5 s each; devices OADM cannot log in to are not contacted.</summary>
private async Task ReadAllAsync(CancellationToken ct)
{
var ctx = _ctx!;
var rows = new ConcurrentBag<FirmwareRow>();
try
{
var devices = await ctx.Devices.ListAsync(ct);
await Parallel.ForEachAsync(devices, new ParallelOptions { MaxDegreeOfParallelism = 16, CancellationToken = ct }, async (device, token) =>
{
rows.Add(await ReadAsync(ctx, device, token));
});
}
catch (OperationCanceledException)
{
return; // the plugin stopped
}
Publish(_state with { Reading = false, Rows = [.. rows.OrderBy(r => r.Address, StringComparer.OrdinalIgnoreCase)] });
}
private static async Task<FirmwareRow> ReadAsync(ICorePluginContext ctx, IDeviceInfo device, CancellationToken ct)
{
var model = device.Model ?? "";
if (DeviceMessages.Refusal(device.Status, refuseUnreachable: true) is { } refusal)
{
return new FirmwareRow(device.Id, device.Address, model, null, refusal);
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
try
{
var vapix = await ctx.Vapix.CreateAsync(device.Id, timeout.Token);
var info = await vapix.GetBasicDeviceInfoAsync(timeout.Token);
return new FirmwareRow(device.Id, device.Address, model, info.Version, null);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return new FirmwareRow(device.Id, device.Address, model, null, DeviceMessages.TimedOut);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new FirmwareRow(device.Id, device.Address, model, null, ex.Message);
}
}
private FirmwareState Current
{
get
{
lock (_gate)
{
return _state;
}
}
}
private bool TryBeginRead()
{
lock (_gate)
{
if (_state.Reading)
{
return false;
}
}
Publish(_state with { Reading = true });
return true;
}
/// <summary>Stores the new state with the next version and sends it to every page that watches.</summary>
private void Publish(FirmwareState state)
{
string json;
lock (_gate)
{
_state = state with { Version = _state.Version + 1 };
json = Serialize(_state);
}
_ctx?.Events?.Publish(FirmwareOverview.StateTopic, json);
}
private static string Serialize(FirmwareState state) => JsonSerializer.Serialize(state, FirmwareOverview.Json);
}

The context offers more: Settings (stored JSON of the plugin, e.g. its configuration), Secrets (encrypt passwords you store), DataDirectory (a private folder for files), Tasks (start tasks on devices, which show in the tasks pane) and Logger (the server log).

The client loader finds the class that implements ICorePluginPage with your plugin id and adds the rail entry. CreateView runs for every view of the page: the rail and each Open in new window get their own context and view model, so keep all state in the view model.

MyCoolPagePage.cs
using Avalonia.Controls;
using Oadm.Sdk.Client;
namespace Oadm.Samples.FirmwareOverview.Client;
/// <summary>Found by the client loader: the rail entry and its page.</summary>
public sealed class FirmwareOverviewPage : ICorePluginPage
{
public string PluginId => FirmwareOverview.PluginId;
public string Title => "Firmware overview";
// Called for every view of the page (the rail page and each "Open in new window"), each with its own context.
public Control CreateView(ICorePluginClientContext ctx) => new FirmwareOverviewView { DataContext = new FirmwareOverviewViewModel(ctx) };
}

The view model holds the page’s state. RunAsync is the subscribe, read, apply loop described above. The rows go into a RangeObservableCollection and are replaced in one step, which keeps the grid fast with thousands of rows.

MyCoolPageViewModel.cs
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Oadm.Sdk.Client;
using Oadm.Sdk.Client.Collections;
namespace Oadm.Samples.FirmwareOverview.Client;
/// <summary>The page: reads the state from the server part, then follows its events; Refresh asks for a new read.</summary>
public sealed partial class FirmwareOverviewViewModel(ICorePluginClientContext ctx) : ObservableObject, IDisposable
{
private readonly CancellationTokenSource _stop = new();
private Task? _run;
private int _version = -1;
/// <summary>The table. One reset per update, also with thousands of devices.</summary>
public RangeObservableCollection<FirmwareRow> Rows { get; } = [];
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
public partial bool IsReading { get; private set; }
[ObservableProperty]
public partial string? ErrorText { get; private set; }
/// <summary>"12 devices, 2 not read".</summary>
[ObservableProperty]
public partial string Summary { get; private set; } = "";
/// <summary>Starts once, when the view is shown first.</summary>
public void Start() => _run ??= RunAsync();
/// <summary>Subscribe, read the whole state, apply the events; after a dropped connection wait and do it again.</summary>
private async Task RunAsync()
{
while (!_stop.IsCancellationRequested)
{
try
{
var events = ctx.WatchEventsAsync(_stop.Token);
Apply(await ctx.InvokeAsync(FirmwareOverview.GetState, null, _stop.Token));
await foreach (var item in events)
{
if (item.Topic == FirmwareOverview.StateTopic)
{
Apply(item.PayloadJson);
}
}
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
ErrorText = ServerErrors.Message(ex);
}
try
{
await Task.Delay(TimeSpan.FromSeconds(2), _stop.Token);
}
catch (OperationCanceledException)
{
return;
}
}
}
[RelayCommand(CanExecute = nameof(CanRefresh))]
private async Task RefreshAsync()
{
try
{
ErrorText = null;
await ctx.InvokeAsync(FirmwareOverview.Refresh, null, _stop.Token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
ErrorText = ServerErrors.Message(ex);
}
}
private bool CanRefresh() => !IsReading;
/// <summary>Shows a state from the server, unless an equal or newer one is shown already.</summary>
public void Apply(string? json)
{
if (json is null || JsonSerializer.Deserialize<FirmwareState>(json, FirmwareOverview.Json) is not { } state || state.Version <= _version)
{
return;
}
_version = state.Version;
IsReading = state.Reading;
ErrorText = null;
Rows.ReplaceAll(state.Rows);
var failed = state.Rows.Count(r => r.Error is not null);
Summary = state.Rows.Count switch
{
0 => "Click Refresh to read the devices.",
1 => "1 device",
_ => $"{state.Rows.Count:N0} devices",
} + (failed > 0 ? $", {failed:N0} not read" : "");
}
public void Dispose()
{
_stop.Cancel();
_stop.Dispose();
}
}

The view uses the shared controls: a toolbar button, the progress row, a status chip for problems, the error text below the table. ui:PageHeader.Subtitle puts one short sentence next to the page title; the page itself starts with its content. No colors or sizes of its own, so the page looks right in dark and light.

MyCoolPageView.axaml
<UserControl 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.FirmwareOverview.Client"
xmlns:m="using:Oadm.Samples.FirmwareOverview"
x:Class="Oadm.Samples.FirmwareOverview.Client.FirmwareOverviewView"
x:DataType="vm:FirmwareOverviewViewModel"
ui:PageHeader.Subtitle="The AXIS OS of every device.">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12">
<ui:ToolbarButton IconKey="refresh" Text="Refresh" Command="{Binding RefreshCommand}" />
<TextBlock Classes="secondary" VerticalAlignment="Center" Text="{Binding Summary}" />
</StackPanel>
<ui:ProgressRow Grid.Row="1" Margin="0,8,0,0" Text="Reading devices" IsActive="{Binding IsReading}" IsVisible="{Binding IsReading}" />
<DataGrid Grid.Row="2" Margin="0,8,0,0" ItemsSource="{Binding Rows}" IsReadOnly="True" CanUserSortColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Address" Width="180" Binding="{Binding Address}" x:DataType="m:FirmwareRow" />
<DataGridTextColumn Header="Model" Width="160" Binding="{Binding Model}" x:DataType="m:FirmwareRow" />
<DataGridTextColumn Header="AXIS OS" Width="140" Binding="{Binding Version}" x:DataType="m:FirmwareRow" />
<DataGridTemplateColumn Header="Problem" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="m:FirmwareRow">
<ui:StatusChip Margin="10,0" Text="{Binding Error}" IsError="True"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<TextBlock Grid.Row="3" Margin="0,8,0,0" Classes="error" TextWrapping="Wrap" Text="{Binding ErrorText}"
IsVisible="{Binding ErrorText, Converter={x:Static ObjectConverters.IsNotNull}}" />
</Grid>
</UserControl>
MyCoolPageView.axaml.cs
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Oadm.Samples.FirmwareOverview.Client;
public sealed partial class FirmwareOverviewView : UserControl
{
public FirmwareOverviewView() => AvaloniaXamlLoader.Load(this);
// The host disposes the view model when the page or its window closes; that ends the watch.
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
(DataContext as FirmwareOverviewViewModel)?.Start();
}
}

FakeCoreContext stands in for the server: a device list, a camera per device, and a channel that collects the published events. FakePageContext connects the page straight to the service, so the page test runs the whole round trip without a network.

Fakes.cs
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Oadm.Sdk.Client;
using Oadm.Sdk.Devices;
using Oadm.Sdk.Plugins;
using Oadm.Sdk.Tasks;
using Oadm.Sdk.Vapix;
namespace Oadm.Samples.FirmwareOverview.Tests;
internal sealed record FakeDevice(string Address, DeviceStatus Status = DeviceStatus.Ok) : IDeviceInfo
{
public Guid Id { get; } = Guid.NewGuid();
public string Serial => "B8A44F631339";
public string? HostName => null;
public string? Model => "P3265-V";
public string? FirmwareVersion => null;
public DeviceCategory Category => DeviceCategory.Camera;
public bool HasVideo => true;
public IReadOnlyList<DeviceApi> Apis => [];
}
/// <summary>A camera that answers basicdeviceinfo with its AXIS OS version.</summary>
internal sealed class FakeCamera(string version) : IVapixClient
{
public int Reads { get; private set; }
public Uri BaseAddress { get; } = new("https://192.0.2.10/");
public Task<BasicDeviceInfo> GetBasicDeviceInfoAsync(CancellationToken ct)
{
Reads++;
return Task.FromResult(new BasicDeviceInfo("B8A44F631339", "P3265-V", null, null, version, null, null));
}
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();
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) => throw new NotSupportedException();
}
/// <summary>The server's context for the core plugin: the devices, their cameras and the published events.</summary>
internal sealed class FakeCoreContext : ICorePluginContext, IDeviceRepository, IVapixClientFactory, IPluginEvents
{
public List<IDeviceInfo> DeviceList { get; } = [];
public Dictionary<Guid, FakeCamera> Cameras { get; } = [];
public Channel<PluginEvent> Published { get; } = Channel.CreateUnbounded<PluginEvent>();
public IDeviceRepository Devices => this;
public IVapixClientFactory Vapix => this;
public ITaskRunner Tasks => throw new NotSupportedException();
public IPluginSettings Settings => throw new NotSupportedException();
public ILogger Logger => NullLogger.Instance;
public IPluginEvents Events => this;
public Task<IReadOnlyList<IDeviceInfo>> ListAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<IDeviceInfo>>(DeviceList);
public Task<IDeviceInfo?> FindAsync(Guid id, CancellationToken ct) => Task.FromResult(DeviceList.FirstOrDefault(d => d.Id == id));
public Task<IVapixClient> CreateAsync(Guid deviceId, CancellationToken ct) => Task.FromResult<IVapixClient>(Cameras[deviceId]);
public void Publish(string topic, string? payloadJson) => Published.Writer.TryWrite(new PluginEvent(topic, payloadJson));
}
/// <summary>The page's context: calls go straight to the server part, events come from its fake context.</summary>
internal sealed class FakePageContext(FirmwareOverviewPlugin plugin, FakeCoreContext server) : ICorePluginClientContext
{
public Task<string?> InvokeAsync(string method, string? payloadJson, CancellationToken ct) => plugin.InvokeAsync(method, payloadJson, ct);
public async IAsyncEnumerable<PluginEvent> WatchEventsAsync([EnumeratorCancellation] CancellationToken ct)
{
await foreach (var item in server.Published.Reader.ReadAllAsync(ct))
{
yield return item;
}
}
}

Test the read, the devices that must not be contacted, a bad method, and that the page ignores older states:

MyCoolPageTests.cs
using System.Text.Json;
using Oadm.Samples.FirmwareOverview.Client;
using Oadm.Sdk.Devices;
namespace Oadm.Samples.FirmwareOverview.Tests;
public sealed class FirmwareOverviewTests
{
[Fact]
public async Task Refresh_reads_every_device_and_publishes_the_table()
{
var (plugin, server) = await StartAsync(("10.0.0.48", DeviceStatus.Ok, "12.11.77"), ("10.0.0.49", DeviceStatus.Ok, "11.11.124"));
await plugin.InvokeAsync(FirmwareOverview.Refresh, null, CancellationToken.None);
var state = await NextStateAsync(server, s => !s.Reading);
Assert.Equal(["10.0.0.48 12.11.77", "10.0.0.49 11.11.124"], state.Rows.Select(r => $"{r.Address} {r.Version}"));
Assert.All(state.Rows, r => Assert.Null(r.Error));
}
[Fact]
public async Task Devices_OADM_cannot_log_in_to_are_not_contacted()
{
var (plugin, server) = await StartAsync(("10.0.0.50", DeviceStatus.CredentialsRequired, "12.11.77"));
await plugin.InvokeAsync(FirmwareOverview.Refresh, null, CancellationToken.None);
var row = Assert.Single((await NextStateAsync(server, s => !s.Reading)).Rows);
Assert.Equal(DeviceMessages.CredentialsRequired, row.Error);
Assert.Equal(0, server.Cameras.Values.Single().Reads);
}
[Fact]
public async Task An_unknown_method_is_refused()
{
var (plugin, _) = await StartAsync();
await Assert.ThrowsAsync<ArgumentException>(() => plugin.InvokeAsync("format", null, CancellationToken.None));
}
[Fact]
public async Task The_page_shows_the_table_and_ignores_older_states()
{
var (plugin, server) = await StartAsync(("10.0.0.48", DeviceStatus.Ok, "12.11.77"));
using var page = new FirmwareOverviewViewModel(new FakePageContext(plugin, server));
await page.RefreshCommand.ExecuteAsync(null);
var state = await NextStateAsync(server, s => !s.Reading);
page.Apply(JsonSerializer.Serialize(state, FirmwareOverview.Json));
page.Apply(JsonSerializer.Serialize(state with { Version = 1, Rows = [] }, FirmwareOverview.Json)); // older: ignored
Assert.Equal("12.11.77", Assert.Single(page.Rows).Version);
Assert.Equal("1 device", page.Summary);
Assert.False(page.IsReading);
}
private static async Task<(FirmwareOverviewPlugin Plugin, FakeCoreContext Server)> StartAsync(params (string Address, DeviceStatus Status, string Version)[] devices)
{
var server = new FakeCoreContext();
foreach (var (address, status, version) in devices)
{
var device = new FakeDevice(address, status);
server.DeviceList.Add(device);
server.Cameras[device.Id] = new FakeCamera(version);
}
var plugin = new FirmwareOverviewPlugin();
await plugin.StartAsync(server, CancellationToken.None);
return (plugin, server);
}
/// <summary>The next published state that matches, within 5 s.</summary>
private static async Task<FirmwareState> NextStateAsync(FakeCoreContext server, Func<FirmwareState, bool> match)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await foreach (var item in server.Published.Reader.ReadAllAsync(timeout.Token))
{
var state = JsonSerializer.Deserialize<FirmwareState>(item.PayloadJson!, FirmwareOverview.Json)!;
if (match(state))
{
return state;
}
}
throw new TimeoutException();
}
}
Terminal window
dotnet test tests/Oadm.Plugins.MyCoolPage.Tests
./manage.sh run dev

The rail shows My cool page under Maintenance. Click Refresh: the progress row shows while the server reads, then the table fills in. Open the page in a second window from the rail entry’s context menu and refresh again: both update.

  1. The rail entry: DisplayName, IconKey, Group.
  2. The state: the row and state records, and what ReadAsync reads per device.
  3. The methods: one constant per method, one case in InvokeAsync, one command in the view model.
  4. The view: columns, buttons, a card layout of your own (set HasOwnCards in the page class).
  5. Settings that stay: store them with ctx.Settings.SetAsync("config", json, ct) and read them in StartAsync.
  6. The tests: one per method and per kind of device answer.

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 devices you tried it.