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.
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 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
2. How a page talks to its service
Section titled “2. How a page talks to its service”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.
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, where administrators turn it on and off.
{ "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.
4. The project files
Section titled “4. The project files”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.
<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.
<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>5. The contract
Section titled “5. The contract”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.
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);}6. The service
Section titled “6. The service”Id,DisplayName,IconKey,Group: the rail entry.Groupplaces it under a heading of the rail (here Maintenance).StartAsyncruns when the server starts and when an administrator turns the plugin on. Keep the context and return at once; never wait for devices here.InvokeAsyncis the page’s backend. ThrowArgumentExceptionfor bad input andInvalidOperationExceptionfor a wrong state: the page gets the message.ReadAllAsyncreads 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.Refusalskips 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.CreateAsyncgives a client that is already logged in to the device. The server keeps it; never dispose it.Publishstores the new state with the next version and sends it to every page.
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).
7. The page
Section titled “7. The page”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.
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.
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.
<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>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(); }}8. The tests
Section titled “8. The tests”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.
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:
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(); }}9. Run it
Section titled “9. Run it”dotnet test tests/Oadm.Plugins.MyCoolPage.Tests./manage.sh run devThe 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.
10. Make it yours
Section titled “10. Make it yours”- The rail entry:
DisplayName,IconKey,Group. - The state: the row and state records, and what
ReadAsyncreads per device. - The methods: one constant per method, one case in
InvokeAsync, one command in the view model. - The view: columns, buttons, a card layout of your own (set
HasOwnCardsin the page class). - Settings that stay: store them with
ctx.Settings.SetAsync("config", json, ct)and read them inStartAsync. - The tests: one per method and per kind of device answer.
11. Share it
Section titled “11. 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 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.