.NET 8.0 Collectible ALC Named Pipe IPC Remote WPF Hosting MinSystemVersion 1.4.0

This step-by-step guide walks software engineers and system integrators through designing, implementing, packaging, and deploying custom protocol plugins for the Uniflow platform.

1. Architectural Overview & Plugin Lifecycle

Uniflow uses a modern, modular dynamic plugin architecture based on .NET 8's collectible AssemblyLoadContext (ALC). Uniflow host applications separate backend execution (UniflowService) from user interaction (Uniflow GUI).

In this architecture, all plugin management and lifecycle operations reside exclusively in the backend UniflowService. There is no Client PluginManager in the GUI application. Instead, custom source editor views and view models are dynamically delivered from the service to the UI on-demand over IPC.

ARCHITECTURE DIAGRAM
┌─────────────────────────────────────────────────────────────────────────────┐
│                       UNIFLOW PLUGIN ARCHITECTURE                           │
└─────────────────────────────────────────────────────────────────────────────┘
  
   ┌────────────────────────────────┐       ┌────────────────────────────────┐
   │         Uniflow GUI            │       │        UniflowService          │
   │  - Dynamic PluginUiProvider    │       │  - Central PluginManager       │
   │  - ClientPluginSessionManager  │       │  - Collectible ALC Sandbox     │
   │  - Remote WPF Control Hosting  │       │  - SourceManager & Adapters    │
   └───────────────┬────────────────┘       └───────────────┬────────────────┘
                   │                                        │
                   │           Named Pipe IPC               │
                   │  - GetPluginUiBundle                   │
                   │  - Binary UI Assembly Delivery         │
                   └───────────────────►◄───────────────────┘
                                        │
                    Reads %ProgramData%\Uniflow\Plugins\
                                        ▼
                   ┌───────────────────────────────────────┐
                   │    Custom Plugin Bundle (.zip)        │
                   │  ├── plugin.json (Manifest)           │
                   │  ├── MyPlugin.dll (WPF + Logic)       │
                   │  └── icon.png (256x256 Display Icon)  │
                   └───────────────────────────────────────┘

Key Plugin Architecture Principles

  1. Service-Centric Plugin Management: UniflowService discovers, validates, licenses, loads, and executes all plugins inside isolated CollectibleAssemblyLoadContext sandboxes.
  2. Remote UI Delivery & On-Demand Pushing: When a user selects or configures a plugin source in the GUI, the client issues a GetPluginUiBundle IPC request. Then UniflowService retrieves the UI assembly bytes (and dependencies) and pushes them over IPC to the GUI.
  3. Client-Side Isolated Session Hosting: The GUI uses PluginUiProvider and ClientPluginSessionManager to load the delivered plugin UI assembly into a light collectible ALC session. It dynamically instantiates the custom editor View (*EditorView UserControl) and ViewModel (*EditorViewModel), binding IIpcClient for communication back to UniflowService.
  4. Dual Execution Package: A single compiled plugin assembly bundle contains both the backend Service Engine Adapter (ISourceAdapter) and the frontend WPF Settings UI (ISourceEditorViewModel + UserControl).
  5. Event-Driven Data Catalog: Plugins expose telemetry tags, events, and executable commands to the visual Rule Engine through structured SourceCatalogSnapshot metadata.

2. Visual Studio Project Setup

Create a new C# Class Library project targeting .NET 8.0 Windows with WPF enabled.

MyCustomPlugin.csproj

XML
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AssemblyName>Uniflow.Plugin.MyCustomPlugin</AssemblyName>
    <RootNamespace>Uniflow.Plugin.MyCustomPlugin</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <!-- Core Uniflow Host Contracts -->
    <Reference Include="UniflowLibs">
      <HintPath>UniflowLibs.dll</HintPath>
      <Private>false</Private> <!-- Must be false to use host runtime assemblies -->
    </Reference>
  </ItemGroup>

  <ItemGroup>
    <!-- UI & MVVM Libraries -->
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
    <PackageReference Include="WPF-UI" Version="3.0.4" />
  </ItemGroup>

  <ItemGroup>
    <!-- Embed icon and manifest -->
    <Content Include="plugin.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
    <EmbeddedResource Include="icon.png" />
  </ItemGroup>

</Project>
TIP

Official Starter Template Archive

To accelerate development, Uniflow provides a pre-configured, ready-to-build sample plugin template.

This standalone project comes bundled with UniflowLibs.dll, plugin.json, icon.png, and a complete reference implementation of SampleSourceAdapter, SampleSourceAdapterFactory, and MVVM editor UI (SampleSourceEditorView & SampleSourceEditorViewModel). Developers can extract this zip to any folder and immediately compile custom plugins without requiring the full Uniflow host source tree.

IMPORTANT

Always set <Private>false</Private> on host reference DLLs (UniflowLibs). This ensures host-provided contracts are resolved at runtime from the host application context rather than duplicated inside the plugin's local ALC.

3. Declare the plugin.json Manifest

Every plugin requires a plugin.json manifest file in its root directory. This manifest informs the service PluginManager about metadata, licensing requirements, entry points, release notes history, and open-source legal attributions.

Manifest Example (plugin.json)

JSON
{
  "Id": "uniflow.plugin.mycustomplugin",
  "Name": "My Custom Protocol",
  "Type": "Industrial Protocols",
  "Version": "1.0.0",
  "Author": "Acme Engineering Team",
  "Description": "Integrates proprietary field telemetry controllers over TCP/IP sockets.",
  "SourceType": "uniflow.plugin.mycustomplugin",
  "EntryAssembly": "Uniflow.Plugin.MyCustomPlugin.dll",
  "MinSystemVersion": "1.0.0",
  "LicensePlan": "Free",
  "Platform": "CrossPlatform",
  "DefaultContextAssemblies": [],
  "ReleaseNotes": [
    {
      "version": "1.0.0",
      "release_note": "Initial release of My Custom Protocol plugin."
    }
  ],
  "legal_info": [
    {
      "library_name": "NLog",
      "version": "5.2.0",
      "license_name": "BSD-3-Clause",
      "license_url": "https://licenses.nuget.org/BSD-3-Clause",
      "license_text": "Copyright (c) 2004-2023 Jaroslaw Kowalski <jaak@jkowalski.net>..."
    }
  ]
}

Field Definitions

ParameterTypeRequiredDescription
IdStringYesUnique reverse-DNS identifier (e.g., uniflow.plugin.mycustomplugin).
NameStringYesHuman-readable title displayed across the UI.
TypeStringYesCategory for filtering (Industrial Protocols, Security &
Video
, Hardware &
Sensors
, Messaging &
Cloud
, Generic Protocol, Custom / Other).
VersionStringYesSemantic versioning format (1.0.0).
AuthorStringNoDeveloper or vendor organization name.
DescriptionStringYesDetailed overview of capabilities.
SourceTypeStringYesString identifier matching SourceTypeId.
EntryAssemblyStringYesFilename of main compiled assembly DLL inside the package.
MinSystemVersionStringNoMinimum compatible Uniflow host version.
LicensePlanStringYesFree or PRO. (PRO plugins require an active PRO license key).
PlatformStringNoTarget operating system support: "CrossPlatform" (default, runs on Windows &
Linux) or "WindowsOnly" (e.g., plugins relying on Windows WinRT APIs like Bluetooth BLE, skipped on Linux).
DefaultContextAssembliesArray of StringNoList of assembly name prefixes (e.g., ["Onvif.", "ServiceModel."]) that must be loaded into the Default (non-collectible) AssemblyLoadContext. Use this when third-party libraries rely on WCF DispatchProxy, Reflection.Emit, or shared dynamic proxy generators.
ReleaseNotesArray / ObjectNoList of version release notes (objects containing version and release_note string fields) displayed in the Plugin Manager dialog.
legal_infoArray / ObjectNoThird-party dependency licenses (objects containing library_name, version, license_name, license_url, and license_text) for open-source compliance.

4. Implement the Service Adapter & Factory

The service adapter runs on UniflowService to handle protocol communication, device polling, data transformation, tag catalog generation, and command execution.

TIP

Logging & Inspection:

Plugins receive an instance of IUniflowLogger from UniflowService via IServiceProvider. Emitting logs through _log.Info(), _log.Debug(), _log.Warn(), and _log.Error() allows users to inspect live protocol activity and debug plugin operation in real time within the Uniflow GUI.

4.1 Create MyCustomSourceAdapter.cs

Inherit from SourceAdapterBase (UniflowService.Sources.Abstractions) and implement IDisposable (or IAsyncDisposable):

IMPORTANT

Resource Disposal Best Practices:

Plugin source adapters MUST cancel background polling loops, dispose active timers (PeriodicTimer), close network sockets/channels, and implement IDisposable (or IAsyncDisposable). Complete resource cleanup in OnStopAsync / Dispose ensures that .NET 8's collectible AssemblyLoadContext unloads plugin instances cleanly without memory leaks when plugins are updated.

CSHARP
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UniflowLibs.Contracts.Sources;
using UniflowLibs.Logging;
using UniflowService.Sources;
using UniflowService.Sources.Abstractions;

namespace Uniflow.Plugin.MyCustomPlugin;

/// <summary>
/// Custom protocol source adapter implementation executing inside UniflowService.
/// Manages device network connections, background telemetry polling, catalog publishing, and write operations.
/// </summary>
public class MyCustomSourceAdapter : SourceAdapterBase, IDisposable
{
    private readonly IUniflowLogger _log;
    private string _host = "127.0.0.1";
    private int _port = 5000;
    private int _pollingIntervalMs = 1000;
    private CancellationTokenSource? _cts;
    private PeriodicTimer? _timer;
    private Task? _pollTask;

    /// <summary>
    /// Initializes a new instance of the <see cref="MyCustomSourceAdapter"/> class.
    /// </summary>
    /// <param name="definition">Source DTO containing target address and settings.</param>
    /// <param name="capabilities">Capabilities flags supported by this adapter instance.</param>
    /// <param name="log">Logger instance for diagnostic logs.</param>
    public MyCustomSourceAdapter(SourceDto definition, SourceCapabilities capabilities, IUniflowLogger log) 
        : base(definition, capabilities)
    {
        _log = log?.ForRegion("MyCustomPlugin") ?? NullUniflowLogger.Instance;
        ParseSettings(definition);
    }

    /// <summary>
    /// Parses configuration settings from the source DTO dictionary.
    /// </summary>
    /// <param name="definition">Source DTO containing settings key-value pairs.</param>
    private void ParseSettings(SourceDto definition)
    {
        if (definition.Settings == null) return;
        if (definition.Settings.TryGetValue("Host", out var h) && !string.IsNullOrEmpty(h)) _host = h;
        if (definition.Settings.TryGetValue("Port", out var p) && int.TryParse(p, out var portVal)) _port = portVal;
        if (definition.Settings.TryGetValue("PollingIntervalMs", out var rate) && int.TryParse(rate, out var rateVal)) _pollingIntervalMs = rateVal;
    }

    /// <summary>
    /// Connects to hardware sockets and launches background polling workers during startup.
    /// </summary>
    /// <param name="ct">Cancellation token for startup sequence.</param>
    protected override async Task OnStartAsync(CancellationToken ct)
    {
        _log.Info("Lifecycle", $"Starting custom protocol adapter for {_host}:{_port}...");
        
        // 1. Establish hardware socket or client connection
        await SetConnectionStateAsync(SourceConnectionState.Connecting, $"Connecting to {_host}:{_port}...");
        
        _cts = new CancellationTokenSource();
        _timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_pollingIntervalMs));

        await SetConnectionStateAsync(SourceConnectionState.Connected, $"Connected to {_host}:{_port}");
        _log.Info("Lifecycle", $"Successfully connected to {_host}:{_port}");
        _pollTask = RunPollLoopAsync(_cts.Token);
    }

    /// <summary>
    /// Disconnects network sockets and cleans up background workers during shutdown.
    /// </summary>
    /// <param name="ct">Cancellation token for shutdown sequence.</param>
    protected override async Task OnStopAsync(CancellationToken ct)
    {
        _log.Info("Lifecycle", "Stopping custom protocol adapter...");
        
        // 2. Tear down sockets, cancel background tasks, and clean up resources
        if (_cts != null)
        {
            _cts.Cancel();
            _timer?.Dispose();
            if (_pollTask != null)
            {
                try { await _pollTask.ConfigureAwait(false); } catch { }
            }
            _cts.Dispose();
            _cts = null;
        }

        await SetConnectionStateAsync(SourceConnectionState.Disconnected, "Adapter stopped.");
        _log.Info("Lifecycle", "Adapter stopped successfully.");
    }

    /// <summary>
    /// Disposes resources held by the source adapter when unloaded from the service ALC sandbox.
    /// </summary>
    public void Dispose()
    {
        OnStopAsync(CancellationToken.None).GetAwaiter().GetResult();
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Tests network connection health to the configured endpoint.
    /// </summary>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>A test result indicating success or error details.</returns>
    protected override Task<SourceTestResult> OnTestConnectionAsync(CancellationToken ct)
    {
        _log.Debug("Diagnostics", $"Testing connection to {_host}:{_port}...");
        // Connection test logic (called from UI editor test button via IPC)
        return Task.FromResult(new SourceTestResult
        {
            Success = true
        });
    }

    /// <summary>
    /// Discovers catalog tag descriptors and commands exposed by this custom protocol.
    /// </summary>
    /// <param name="forceRefresh">Forces live device refresh if true.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>A catalog snapshot containing available telemetry items.</returns>
    protected override Task<SourceCatalogSnapshot> OnBrowseCatalogAsync(bool forceRefresh, CancellationToken ct)
    {
        _log.Debug("Catalog", "Browsing available tags for custom protocol...");
        // 3. Expose available data points to Uniflow Data Catalog & Rule Engine
        var catalog = BuildEmptyCatalogSnapshot();

        catalog.Items.Add(new SourceItemDescriptor
        {
            Id = "sensor_temp",
            DisplayName = "Ambient Temperature",
            Path = "Sensors/Temperature",
            Category = "Telemetry",
            Kind = SourceItemKind.Value,
            Access = SourceItemAccess.Read,
            ValueType = SourceValueType.Double,
            Unit = "°C",
            IsBrowsable = true,
            IsCached = true,
            SupportsMonitoring = true
        });

        catalog.Items.Add(new SourceItemDescriptor
        {
            Id = "relay_state",
            DisplayName = "Relay Output Switch",
            Path = "Control/Relay1",
            Category = "Actuators",
            Kind = SourceItemKind.Value,
            Access = SourceItemAccess.ReadWrite,
            ValueType = SourceValueType.Bool,
            IsBrowsable = true,
            IsCached = true,
            SupportsMonitoring = true
        });

        return Task.FromResult(catalog);
    }

    /// <summary>
    /// Reads live tag values from device hardware during periodic polling cycles.
    /// </summary>
    /// <param name="itemIds">Specific tag IDs to poll, or null for all monitored tags.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>A snapshot containing updated telemetry values.</returns>
    protected override Task<SourceValuesSnapshot> OnRefreshValuesAsync(IReadOnlyCollection<string>? itemIds, CancellationToken ct)
    {
        _log.Debug("Polling", "Polling telemetry tag values from device...");

        // 1. RAW TRAFFIC TELEMETRY (Plugin-level wire / socket traffic)
        // If your custom plugin reads raw bytes from sockets, serial ports, or HTTP endpoints,
        // call ReportTraffic(bytesIn, bytesOut) to report wire I/O.
        // This powers the "Plugin Throughput" graphs and totals on the Uniflow Dashboard.
        ReportTraffic(bytesIn: 64, bytesOut: 0);

        // 2. APPLICATION-LEVEL TELEMETRY
        // SourceAdapterBase automatically tracks application bytes based on values inside SourceValuesSnapshot.
        var snapshot = BuildEmptyValuesSnapshot(fullSnapshot: false);

        snapshot.Values.Add(new SourceItemValue
        {
            ItemId = "sensor_temp",
            Timestamp = DateTimeOffset.UtcNow,
            Value = SourceValueData.FromDouble(23.45) // Read live value from protocol
        });

        snapshot.Values.Add(new SourceItemValue
        {
            ItemId = "relay_state",
            Timestamp = DateTimeOffset.UtcNow,
            Value = SourceValueData.FromBool(true)
        });

        return Task.FromResult(snapshot);
    }

    /// <summary>
    /// Gets the current source traffic snapshot containing cumulative byte totals and real-time throughput rates.
    /// </summary>
    /// <remarks>
    /// Inherited implementation from SourceAdapterBase automatically returns calculated Application-level
    /// and Plugin-level metrics calculated over a sliding 10-second window.
    /// </remarks>
    public override SourceTrafficSnapshot GetTrafficSnapshot()
    {
        return base.GetTrafficSnapshot();
    }

    /// <summary>
    /// Writes an output command value to a writable tag item (e.g. relay switch).
    /// </summary>
    /// <param name="itemId">Target tag item ID.</param>
    /// <param name="value">New value data payload to write.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>True if write succeeded; otherwise, false.</returns>
    public override Task<bool> WriteValueAsync(string itemId, SourceValueData value, CancellationToken ct)
    {
        _log.Info("Control", $"Writing value '{value}' to item '{itemId}'...");
        // 3. Handle outbound writes from Rule Engine "Output Target" nodes
        if (itemId == "relay_state")
        {
            bool state = value.BoolValue ?? false;
            // Transmit protocol write command to device hardware (e.g. 8 bytes sent over socket)...
            ReportTraffic(bytesIn: 0, bytesOut: 8);
            return Task.FromResult(true);
        }
        return Task.FromResult(false);
    }
}

4.2 Source Traffic Telemetry (ReportTraffic & GetTrafficSnapshot)

Uniflow features a dual-layer traffic telemetry collection engine that measures data throughput across all active sources and plugins in real time.

ARCHITECTURE DIAGRAM
┌─────────────────────────────────────────────────────────────────────────────┐
│                    DUAL-LAYER SOURCE TRAFFIC TELEMETRY                       │
│                                                                             │
│  ┌───────────────────────────────────────────────────────────────────────┐  │
│  │ 1. Application-Level Throughput                                        │  │
│  │    - Structured tag values (Double, Int, String, Json, Images)        │  │
│  │    - Automatically measured by SourceAdapterBase on value events      │  │
│  └───────────────────────────────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────────────────────────────┐  │
│  │ 2. Plugin-Level Raw I/O Throughput                                    │  │
│  │    - Low-level network socket bytes, HTTP body sizes, file read/writes│  │
│  │    - Reported by plugin adapters via ReportTraffic(bytesIn, bytesOut) │  │
│  └───────────────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────────┘

Dual-Layer Throughput Metrics

Metric CategoryData FieldsCalculation Method
Application-LevelAppBytesIn, AppBytesOut, AppRateIn, AppRateOutAutomatic: SourceAdapterBase inspects data types (Bools=1B, Ints=4B, Strings/JSON=UTF16 length, Bytes=array length) whenever values or snapshots are published/read/written.
Plugin-Level (Raw I/O)PluginBytesIn, PluginBytesOut, PluginRateIn, PluginRateOutManual / Direct: Plugin adapters call ReportTraffic(bytesIn, bytesOut) during raw socket receive/send, HTTP requests, or file I/O operations.

Reporting Raw Protocol Bytes (ReportTraffic)

When your custom plugin performs low-level socket, serial, HTTP, or file transfer operations, invoke ReportTraffic:

CSHARP
// Example: Receiving 256 bytes from TCP socket
ReportTraffic(bytesIn: 256, bytesOut: 0);

// Example: Transmitting a 64-byte command frame to hardware
ReportTraffic(bytesIn: 0, bytesOut: 64);

Calling ReportTraffic automatically sets HasPluginTraffic = true in the adapter's snapshot, notifying the Uniflow GUI Dashboard to display both Application Throughput and Plugin Throughput sparkline graphs side-by-side.

Querying Traffic Snapshots (GetTrafficSnapshot)

SourceAdapterBase provides a complete default implementation of GetTrafficSnapshot(). It returns a SourceTrafficSnapshot containing:

CSHARP
public sealed class SourceTrafficSnapshot
{
    public string SourceId { get; set; } = string.Empty;
    public long AppBytesIn { get; set; }
    public long AppBytesOut { get; set; }
    public long AppRateIn { get; set; }        // Bytes per second (10-second sliding window)
    public long AppRateOut { get; set; }       // Bytes per second (10-second sliding window)
    public long PluginBytesIn { get; set; }
    public long PluginBytesOut { get; set; }
    public long PluginRateIn { get; set; }     // Bytes per second (10-second sliding window)
    public long PluginRateOut { get; set; }    // Bytes per second (10-second sliding window)
    public bool HasPluginTraffic { get; set; }
    public DateTimeOffset Timestamp { get; set; }
}
NOTE

Custom plugins only need to override GetTrafficSnapshot() if they maintain external hardware counters outside SourceAdapterBase or require custom telemetry logging.


4.3 Create MyCustomSourceAdapterFactory.cs

Implement ISourceAdapterFactory to handle adapter creation:

CSHARP
using UniflowLibs.Contracts.Sources;
using UniflowLibs.Logging;
using UniflowService.Sources.Abstractions;

namespace Uniflow.Plugin.MyCustomPlugin;

/// <summary>
/// Factory for constructing <see cref="MyCustomSourceAdapter"/> instances inside UniflowService.
/// </summary>
public class MyCustomSourceAdapterFactory : ISourceAdapterFactory
{
    private readonly string _sourceTypeId;
    private readonly IUniflowLogger _log;

    /// <summary>
    /// Initializes a new instance of the <see cref="MyCustomSourceAdapterFactory"/> class.
    /// </summary>
    /// <param name="sourceTypeId">Plugin source type identifier string.</param>
    /// <param name="log">Host logger instance.</param>
    public MyCustomSourceAdapterFactory(string sourceTypeId, IUniflowLogger log)
    {
        _sourceTypeId = sourceTypeId;
        _log = log ?? NullUniflowLogger.Instance;
    }

    /// <summary>
    /// Checks whether this factory handles creation for the requested source type.
    /// </summary>
    /// <param name="sourceType">Source type identifier string.</param>
    /// <returns>True if supported; otherwise, false.</returns>
    public bool CanCreate(SourceType sourceType) => sourceType.ToString() == _sourceTypeId;

    /// <summary>
    /// Creates a new <see cref="MyCustomSourceAdapter"/> instance with configured capabilities.
    /// </summary>
    /// <param name="definition">Source DTO definition.</param>
    /// <returns>An initialized <see cref="ISourceAdapter"/> instance.</returns>
    public ISourceAdapter Create(SourceDto definition)
    {
        var capabilities = new SourceCapabilities
        {
            CanReadValues = true,
            CanWriteValues = true,
            CanBrowseCatalog = true,
            CanExecuteCommands = false
        };
        return new MyCustomSourceAdapter(definition, capabilities, _log);
    }
}

5. Design the WPF Configuration UI

Plugins provide a custom WPF editor UI for settings such as IP addresses, ports, credentials, and polling rates.

Because the UI app does not host PluginManager, the UI bundle is served dynamically from UniflowService via IPC (GetPluginUiBundle). The client's PluginUiProvider inspects the loaded assembly using:

  • Contract (Recommended): ISourcePluginDefinition.EditorViewType pointing to typeof(MyCustomSourceEditorView) and CreateEditorViewModel(...).
  • Standard Naming Convention:
  • View: <PluginName>SourceEditorView (e.g., MyCustomSourceEditorView) inheriting from UserControl.
  • ViewModel: <PluginName>SourceEditorViewModel (e.g., MyCustomSourceEditorViewModel) implementing ISourceEditorViewModel.

5.1 Implement ViewModel (MyCustomSourceEditorViewModel.cs)

IMPORTANT

Deterministic Unsaved Changes Tracking:

All source editor view models MUST implement SourceDto BuildCurrentDto() required by ISourceEditorViewModel. The host application calls BuildCurrentDto() to generate a deterministic JSON snapshot of the editor's current settings state, comparing it against the original state to detect unsaved changes accurately without relying on UI events.

CSHARP
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UniflowLibs.Contracts.Ipc;
using UniflowLibs.Contracts.Sources;

namespace Uniflow.Plugin.MyCustomPlugin;

/// <summary>
/// Source editor ViewModel for configuring custom protocol settings in the Uniflow GUI.
/// Implements ISourceEditorViewModel to bind UI controls and communicate with UniflowService via IPC.
/// </summary>
public partial class MyCustomSourceEditorViewModel : ObservableObject, ISourceEditorViewModel
{
    private readonly IIpcClient? _ipc;
    private readonly SourceDto _source;

    [ObservableProperty] private string _host = "127.0.0.1";
    [ObservableProperty] private int _port = 5000;
    [ObservableProperty] private int _pollingIntervalMs = 1000;
    [ObservableProperty] private string _statusMessage = "";

    /// <summary>
    /// Gets the plugin source type identifier string.
    /// </summary>
    public string SourceType { get; }

    /// <summary>
    /// Gets the command for saving current configuration to UniflowService.
    /// </summary>
    public IAsyncRelayCommand SaveCommand { get; }

    /// <summary>
    /// Gets the command for canceling changes and dismissing the editor dialog.
    /// </summary>
    public IRelayCommand CancelCommand { get; }

    /// <summary>
    /// Gets the command for testing network connection health to the hardware endpoint.
    /// </summary>
    public IAsyncRelayCommand TestConnectionCommand { get; }

    /// <summary>
    /// Event raised when source configuration is successfully saved.
    /// </summary>
    public event Action? Saved;

    /// <summary>
    /// Event raised when editing is canceled.
    /// </summary>
    public event Action? Cancelled;

    /// <summary>
    /// Initializes a new instance of the <see cref="MyCustomSourceEditorViewModel"/> class.
    /// </summary>
    /// <param name="ipc">IPC client instance.</param>
    /// <param name="source">Source configuration DTO.</param>
    /// <param name="sourceType">Plugin source type identifier.</param>
    public MyCustomSourceEditorViewModel(IIpcClient? ipc, SourceDto source, string sourceType)
    {
        _ipc = ipc;
        _source = source;
        SourceType = sourceType;

        // Load existing settings
        if (source.Settings != null)
        {
            if (source.Settings.TryGetValue("Host", out var h)) Host = h;
            if (source.Settings.TryGetValue("Port", out var p) && int.TryParse(p, out var portVal)) Port = portVal;
            if (source.Settings.TryGetValue("PollingIntervalMs", out var rate) && int.TryParse(rate, out var rateVal)) PollingIntervalMs = rateVal;
        }

        SaveCommand = new AsyncRelayCommand(OnSaveAsync);
        CancelCommand = new RelayCommand(() => Cancelled?.Invoke());
        TestConnectionCommand = new AsyncRelayCommand(OnTestConnectionAsync);
    }

    /// <summary>
    /// Builds a SourceDto representing the current state of the editor.
    /// Used by host application for deterministic JSON dirty state tracking.
    /// </summary>
    public SourceDto BuildCurrentDto()
    {
        return new SourceDto
        {
            Id = _source.Id,
            SourceType = SourceType,
            Name = _source.Name,
            Enabled = _source.Enabled,
            Notes = _source.Notes,
            Settings = new Dictionary<string, string>
            {
                ["Host"] = Host,
                ["Port"] = Port.ToString(),
                ["PollingIntervalMs"] = PollingIntervalMs.ToString()
            }
        };
    }

    /// <summary>
    /// Asynchronously saves updated settings to UniflowService over IPC.
    /// </summary>
    private async Task OnSaveAsync()
    {
        if (_ipc == null) return;

        var dto = BuildCurrentDto();
        _source.Settings = dto.Settings;

        var resp = await _ipc.SendAsync(new IpcRequestEnvelope
        {
            Command = IpcCommand.UpsertSource,
            Payload = new UpsertSourceRequest { Source = _source },
            ClientId = "Uniflow.GUI"
        }, default);

        if (resp.Code == IpcResultCode.Ok)
            Saved?.Invoke();
        else
            StatusMessage = $"Save Failed: {resp.ErrorMessage}";
    }

    /// <summary>
    /// Asynchronously tests connection health to the hardware endpoint over IPC.
    /// </summary>
    private async Task OnTestConnectionAsync()
    {
        if (_ipc == null) return;

        StatusMessage = "Testing connection...";
        var resp = await _ipc.SendAsync(new IpcRequestEnvelope
        {
            Command = IpcCommand.TestSourceConnection,
            Payload = new TestSourceConnectionRequest { Source = _source },
            ClientId = "Uniflow.GUI"
        }, default);

        StatusMessage = resp.Code == IpcResultCode.Ok ? "Connection successful!" : $"Connection failed: {resp.ErrorMessage}";
    }
}

5.2 Create WPF UserControl View (MyCustomSourceEditorView.xaml)

XML
<UserControl x:Class="Uniflow.Plugin.MyCustomPlugin.MyCustomSourceEditorView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
             MinWidth="420">
    <StackPanel Margin="15">
        <TextBlock Text="Custom Protocol Configuration" FontSize="18" FontWeight="Bold" Margin="0,0,0,15" Foreground="White"/>

        <TextBlock Text="Target Host / IP Address:" Margin="0,5" Foreground="LightGray"/>
        <ui:TextBox Text="{Binding Host, UpdateSourceTrigger=PropertyChanged}" PlaceholderText="e.g. 192.168.1.100"/>

        <TextBlock Text="TCP Port:" Margin="0,12,0,5" Foreground="LightGray"/>
        <ui:NumberBox Value="{Binding Port, UpdateSourceTrigger=PropertyChanged}" Minimum="1" Maximum="65535"/>

        <TextBlock Text="Polling Interval (ms):" Margin="0,12,0,5" Foreground="LightGray"/>
        <ui:NumberBox Value="{Binding PollingIntervalMs, UpdateSourceTrigger=PropertyChanged}" Minimum="100" Maximum="60000"/>

        <TextBlock Text="{Binding StatusMessage}" Foreground="Teal" Margin="0,10" TextWrapping="Wrap"/>
    </StackPanel>
</UserControl>

6. Helper Windows for Catalog & Topic Exploration in Plugin Editors

Complex enterprise plugins (such as Avigilon, Milestone, OpenRouter, or custom protocols) often require custom WPF helper windows to allow system integrators to visually browse, search, and configure topics, subtopics, event categories, model catalogs, or security channels.

6.1 Purpose & Contract of Source Editor Helper Windows

Instead of requiring integrators to manually type raw protocol subscription strings, model names, or event GUIDs, custom plugins expose a helper modal dialog by overriding two members on ISourcePluginDefinition:

  1. bool HasHelperWindow => true; — Tells the Uniflow GUI to render a Helper button on the source's row in the Sources table.
  2. object? CreateHelperWindow(SourceDto source) — Called directly by Uniflow GUI when the user clicks the Helper button. The plugin decodes the source settings, instantiates its WPF Window (and ViewModel if applicable), and returns the window object.
ARCHITECTURE DIAGRAM
┌─────────────────────────────────────────────────────────────────────────────┐
│                      PLUGIN EDITOR HELPER DIALOG FLOW                       │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌──────────────────────────────┐    Click Helper    ┌───────────────────────────┐
  │  Sources List Row            ├───────────────────►│ PluginDefinition          │
  │  [ Source Row ] [ Helper 🔍 ]│                    │ .CreateHelperWindow(source)│
  └──────────────────────────────┘                    └─────────────┬─────────────┘
                                                                    │
                                                     Instantiates   │ Configures VM
                                                     WPF Dialog     │ & API Clients
                                                                    ▼
                                                      ┌───────────────────────────┐
                                                      │ Topic/Catalog Helper      │
                                                      │ Configuration Window      │
                                                      └─────────────┬─────────────┘
                                                                    │
                                                     Save & Return  │ Normalized
                                                     Settings       │ Selections
                                                                    ▼
                                                      ┌───────────────────────────┐
                                                      │ Updated SourceDto.Settings│
                                                      └───────────────────────────┘

6.2 Standard Implementation Pattern

1. Define the WPF Dialog (MyCustomHelperDialog.xaml & .xaml.cs)

Create a standard WPF Window (or ui:FluentWindow) inside your plugin frontend views:

CSHARP
using System.Windows;
using UniflowLibs.Contracts.Sources;

namespace Uniflow.Plugin.MyCustomPlugin;

/// <summary>
/// Interaction logic for MyCustomHelperDialog.xaml window.
/// </summary>
public partial class MyCustomHelperDialog : Window
{
    /// <summary>
    /// Initializes a new instance of the <see cref="MyCustomHelperDialog"/> class.
    /// </summary>
    public MyCustomHelperDialog()
    {
        InitializeComponent();
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="MyCustomHelperDialog"/> class with source context.
    /// </summary>
    /// <param name="source">Source configuration DTO.</param>
    public MyCustomHelperDialog(SourceDto source) : this()
    {
        // Decode source settings and initialize ViewModel / Catalog Data
    }

    /// <summary>
    /// Closes the helper dialog window.
    /// </summary>
    private void Close_Click(object sender, RoutedEventArgs e)
    {
        Close();
    }
}

2. Implement CreateHelperWindow in ISourcePluginDefinition

CSHARP
/// <summary>
/// Definition class implementing ISourcePluginDefinition for plugin loading.
/// </summary>
public class MyCustomPluginDefinition : ISourcePluginDefinition
{
    /// <summary>
    /// Unique source type identifier.
    /// </summary>
    public string SourceTypeId => "uniflow.plugin.mycustomplugin";

    /// <summary>
    /// Indicates that this plugin provides a custom helper window.
    /// </summary>
    public bool HasHelperWindow => true;

    /// <summary>
    /// Constructs and returns the fully initialized Helper Window instance.
    /// </summary>
    /// <param name="source">Source DTO context.</param>
    /// <returns>Initialized helper window dialog.</returns>
    public object? CreateHelperWindow(SourceDto source)
    {
        return new MyCustomHelperDialog(source);
    }
}

6.3 Topic & Catalog Bucket Pattern (e.g., Avigilon Topics Helper)

In the Avigilon plugin, AvigilonTopicsConfigurationViewModel categorizes complex Socket.IO event subscriptions into hierarchical buckets:

  • Bucket Categories: DEVICE, APPLICATION, STORAGE, SYSTEM, USER, ALARM, ACCESS_CONTROL.
  • Atomic Events: Individual subscription filters such as DEVICE_CONNECTED, ALARM_TRIGGERED, ACCESS_CONTROL_DOOR_FORCED.
CSHARP
/// <summary>
/// ViewModel for configuring hierarchical topic subscriptions in the Avigilon Helper dialog.
/// </summary>
public partial class AvigilonTopicsConfigurationViewModel : ObservableObject
{
    [ObservableProperty] private MainTopicItem? _selectedMainTopic;
    [ObservableProperty] private BucketItem? _selectedBucket;
    [ObservableProperty] private string _searchAtomicEventsText = string.Empty;

    /// <summary>
    /// Gets the collection of main topic categories.
    /// </summary>
    public ObservableCollection<MainTopicItem> MainTopics { get; } = new();

    /// <summary>
    /// Gets the collection of topic bucket items under the selected category.
    /// </summary>
    public ObservableCollection<BucketItem> Buckets { get; } = new();

    /// <summary>
    /// Gets atomic event items filtered by search text.
    /// </summary>
    public IEnumerable<AtomicEventItem> FilteredAtomicEvents =>
        SelectedBucket == null ? Array.Empty<AtomicEventItem>() :
        string.IsNullOrWhiteSpace(SearchAtomicEventsText) ? SelectedBucket.AtomicEvents :
        SelectedBucket.AtomicEvents.Where(e => e.Name.Contains(SearchAtomicEventsText, StringComparison.OrdinalIgnoreCase));

    /// <summary>
    /// Formats checked atomic event selections into comma-separated CSV strings for source settings.
    /// </summary>
    /// <returns>Formatted CSV string of topic selections.</returns>
    public string GetSelectedSubtopicsCsv()
    {
        var resultList = new List<string>();
        foreach (var bucket in Buckets)
        {
            if (bucket.IsSelected)
            {
                var checkedChildren = bucket.AtomicEvents.Where(c => c.IsSelected).ToList();
                if (checkedChildren.Count > 0)
                    resultList.AddRange(checkedChildren.Select(c => c.Name));
                else
                    resultList.Add(bucket.Name);
            }
        }
        return string.Join(",", resultList);
    }
}

7. Implement ISourcePluginDefinition Entry Point

ISourcePluginDefinition (UniflowLibs.Contracts.Plugins) is the root contract discovered by UniflowService's PluginManager via reflection when loading the plugin.

Create MyCustomPluginDefinition.cs

CSHARP
using System;
using System.Windows.Controls;
using UniflowLibs.Contracts.Plugins;
using UniflowLibs.Contracts.Sources;
using UniflowLibs.Contracts.Ipc;
using UniflowLibs.Logging;
using UniflowService.Sources.Abstractions;

namespace Uniflow.Plugin.MyCustomPlugin;

/// <summary>
/// Root entry point definition for the custom protocol plugin.
/// Exposes metadata, UI views, factory creators, helper window instances, and rule engine descriptors.
/// </summary>
public class MyCustomPluginDefinition : ISourcePluginDefinition
{
    /// <summary>
    /// Gets the unique plugin source type identifier string.
    /// </summary>
    public string SourceTypeId => "uniflow.plugin.mycustomplugin";

    /// <summary>
    /// Gets the human-readable title of the plugin.
    /// </summary>
    public string Name => "My Custom Protocol";

    /// <summary>
    /// Gets the detailed description of plugin capabilities.
    /// </summary>
    public string Description => "Integrates proprietary field telemetry controllers over TCP/IP sockets.";

    /// <summary>
    /// Gets the 256x256 display icon byte array.
    /// </summary>
    public byte[] SourceImage => GetEmbeddedIconBytes();

    /// <summary>
    /// Creates the backend adapter factory used by UniflowService.
    /// </summary>
    /// <param name="sp">Host service provider.</param>
    /// <returns>An instance of ISourceAdapterFactory.</returns>
    public ISourceAdapterFactory CreateAdapterFactory(IServiceProvider sp)
    {
        var log = (IUniflowLogger?)sp.GetService(typeof(IUniflowLogger)) ?? NullUniflowLogger.Instance;
        return new MyCustomSourceAdapterFactory(SourceTypeId, log);
    }

    /// <summary>
    /// Creates the WPF source editor ViewModel.
    /// </summary>
    /// <param name="sp">Host service provider.</param>
    /// <param name="source">Source DTO instance.</param>
    /// <returns>Editor ViewModel instance implementing ISourceEditorViewModel.</returns>
    public object CreateEditorViewModel(IServiceProvider sp, SourceDto? source)
    {
        var ipc = (IIpcClient?)sp.GetService(typeof(IIpcClient));
        return new MyCustomSourceEditorViewModel(ipc, source ?? new SourceDto { SourceType = SourceTypeId }, SourceTypeId);
    }

    /// <summary>
    /// Gets the WPF UserControl View type.
    /// </summary>
    public Type EditorViewType => typeof(MyCustomSourceEditorView);

    /// <summary>
    /// Gets a value indicating whether a custom helper window button is rendered in the sources table.
    /// </summary>
    public bool HasHelperWindow => true;

    /// <summary>
    /// Instantiates and returns the custom helper window dialog.
    /// </summary>
    /// <param name="source">Source DTO context.</param>
    /// <returns>A WPF Window instance.</returns>
    public object? CreateHelperWindow(SourceDto source)
    {
        return new MyCustomHelperDialog(source);
    }

    /// <summary>
    /// Gets rule engine field descriptors for dynamic Rule Editor parameter resolution.
    /// </summary>
    public PluginRuleIntegration? RuleIntegration => new PluginRuleIntegration
    {
        FieldDescriptors = new List<RuleFieldDescriptor>
        {
            new RuleFieldDescriptor
            {
                TargetItemId = "relay_state",
                ArgumentKey = "Value",
                EditorKind = RuleFieldEditorKind.Boolean
            }
        }
    };

    /// <summary>
    /// Reads and returns the embedded PNG icon file from assembly resources.
    /// </summary>
    /// <returns>Raw byte array of the embedded icon.</returns>
    private byte[] GetEmbeddedIconBytes()
    {
        using var stream = GetType().Assembly.GetManifestResourceStream("Uniflow.Plugin.MyCustomPlugin.icon.png");
        if (stream == null) return Array.Empty<byte>();
        var bytes = new byte[stream.Length];
        stream.Read(bytes, 0, bytes.Length);
        return bytes;
    }
}

{

using var stream = GetType().Assembly.GetManifestResourceStream("Uniflow.Plugin.MyCustomPlugin.icon.png");

if (stream == null) return Array.Empty<byte>();

var bytes = new byte[stream.Length];

stream.Read(bytes, 0, bytes.Length);

return bytes;

}

}

```

8. Rules Engine Integration & Custom Field Descriptors

Plugins customize how their items behave inside the Rule Editor property panel by declaring a PluginRuleIntegration object.

8.1 PluginRuleIntegration & RuleFieldDescriptor Contracts

The PluginRuleIntegration structure allows plugins to specify field validation, dropdown selections, event filtering, and custom collection helpers:

CSHARP
public sealed class PluginRuleIntegration
{
    public List<RuleFieldDescriptor> FieldDescriptors { get; set; } = new();
    public List<string> EventOnlyItemIds { get; set; } = new();
    public List<string> DataOnlyItemIds { get; set; } = new();
}

public sealed class RuleFieldDescriptor
{
    public string TargetItemId { get; set; } = "";     // e.g. "avigilon:alarm" or "dblink:table:*"
    public string ArgumentKey { get; set; } = "";      // e.g. "AlarmId", "SetFields", "WhereFields"
    public RuleFieldEditorKind EditorKind { get; set; } = RuleFieldEditorKind.Text;

    // CollectionHelper Properties (1-Column Selection)
    public string? HelperCollectionItemId { get; set; } // Catalog item ID to browse, or "$self:arguments" for node's own arguments
    public string? HelperDialogTitle { get; set; }        // Picker window title
    public string? HelperDisplayProperty { get; set; }     // Display label property (e.g. "Name")
    public string? HelperValueProperty { get; set; }       // Property copied to field (e.g. "Id")
    public string? HelperSubtitleProperty { get; set; }    // Subtitle property (e.g. "State")
    public bool IsMultiSelect { get; set; }                // Enables multi-selection with checkboxes
    public string MultiSelectSeparator { get; set; } = ","; // Separator string to join selected values (defaults to ",")
    public string? SelfArgumentExcludeKeys { get; set; }  // Comma-separated list of keys to exclude when HelperCollectionItemId is "$self:arguments"

    // TwoColumnHelper Properties (2-Column Parent/Child Selection)
    public string? HelperColumn1Title { get; set; }        // Parent column header title (e.g. "Select Camera")
    public string? HelperColumn2Title { get; set; }        // Child column header title (e.g. "Select PTZ Preset")
    public string? HelperChildCollectionProperty { get; set; } // Dot-notation child array path (e.g. "ptzInfo.presets")
    public string? HelperChildDisplayProperty { get; set; }  // Child display label property (e.g. "Name")
    public string? HelperChildValueProperty { get; set; }    // Child value property (e.g. "Id")
    public string? HelperChildSubtitleProperty { get; set; } // Child subtitle property
    public string? HelperParentArgumentKey { get; set; }   // Target node field key for parent ID (e.g. "Id")
    public string? HelperChildArgumentKey { get; set; }    // Target node field key for child ID (e.g. "PresetId")

    // Dropdown & Validation
    public List<string>? AllowedValues { get; set; }
    public string? ValidationPattern { get; set; }
    public string? ValidationMessage { get; set; }
    public bool TriggersPortRegeneration { get; set; }
}

public enum RuleFieldEditorKind
{
    Text,               // Simple text input box
    Numeric,            // Number input field
    Dropdown,           // Constrained selection list from AllowedValues
    CollectionHelper,   // Button opening a 1-column catalog item picker dialog
    DateTimeHelper,     // Button opening an interactive Date/Time picker dialog
    Boolean,            // Checkbox control
    ReadOnly,           // Display-only text label
    TwoColumnHelper     // Button opening a 2-column parent/child collection picker dialog
}

8.2 RuleFieldEditorKind Enum Reference

The RuleFieldEditorKind enum determines the WPF property editor control rendered in the Rule Editor inspector when configuring graph node arguments:

RuleFieldEditorKind EnumRendered Control / DialogPrimary Use CasesConfigurable Descriptor Properties
TextStandard text boxFree-form string parameters (e.g. URLs, names, payloads).ValidationPattern, ValidationMessage
NumericNumber input boxInteger and floating-point parameters (e.g. ports, durations, limits).ValidationPattern, ValidationMessage
DropdownCombo box dropdownSelection from fixed enum options (e.g. START/STOP actions, formats).AllowedValues
CollectionHelperHelper button (🔍) CollectionItemPickerDialogBrowsing single or multi-select items from source catalog collections or node arguments ($self:arguments).HelperCollectionItemId, HelperDialogTitle, HelperDisplayProperty, HelperValueProperty, HelperSubtitleProperty, IsMultiSelect, MultiSelectSeparator, SelfArgumentExcludeKeys
DateTimeHelperHelper button DateTimePickerDialogPicking timestamps with quick presets (Now, Today, -1h) and ISO 8601 UTC/Local or UNIX epoch output formats.HelperDialogTitle
BooleanWPF CheckBoxTrue/False binary toggle options.N/A
ReadOnlyNon-editable text labelDisplay-only status, static keys, or immutable metadata parameters.N/A
TwoColumnHelperHelper button (🔍) TwoColumnPickerDialogMulti-level parent/child catalog selection (e.g. Camera + PTZ Preset, Server + Channel). Binds parent and child values simultaneously.HelperCollectionItemId, HelperDialogTitle, HelperColumn1Title, HelperColumn2Title, HelperDisplayProperty, HelperValueProperty, HelperSubtitleProperty, HelperChildCollectionProperty, HelperChildDisplayProperty, HelperChildValueProperty, HelperChildSubtitleProperty, HelperParentArgumentKey, HelperChildArgumentKey

8.3 Generic Plugin-Driven UI Metadata (SourceCommandParameterDescriptor)

Uniflow provides a fully generic, metadata-driven inspector framework that allows plugin authors to control dynamic UI behavior without hardcoding any UI logic into the core application engine.

Plugins attach declarative metadata to SourceCommandParameterDescriptor objects inside catalog items:

CSHARP
public sealed class SourceCommandParameterDescriptor
{
    public string Name { get; set; } = "";
    public string DisplayName { get; set; } = "";
    public SourceValueType ValueType { get; set; } = SourceValueType.String;
    public bool Required { get; set; }
    public List<string>? AllowedValues { get; set; }
    public string? Notes { get; set; }

    /// <summary>Visual header section name in the inspector panel.</summary>
    public string? GroupName { get; set; }

    /// <summary>Conditional visibility expression (e.g. "Action=UPDATE&amp;UpdateMode=WHERE").</summary>
    public string? VisibleWhen { get; set; }

    /// <summary>Default value assigned when the argument is first created.</summary>
    public string? DefaultValue { get; set; }

    /// <summary>When true, this field is inspector-only and generates no input port on the canvas.</summary>
    public bool HiddenFromPorts { get; set; }

    /// <summary>When true, this parameter's selected values dynamically filter which sibling ports appear on the canvas.</summary>
    public bool IsPortFilter { get; set; }

    /// <summary>Prefix prepended to port keys generated from this filter field's values (e.g., "where_").</summary>
    public string? PortKeyPrefix { get; set; }

    /// <summary>Prefix prepended to port labels generated from this filter field's values (e.g., "WHERE ").</summary>
    public string? PortLabelPrefix { get; set; }
}

Metadata Field Explanations

  1. GroupName:

Groups parameters under shared visual category headers in the Rule Editor inspector panel. Adjacent parameters with matching GroupName strings are grouped under a single header.

  1. VisibleWhen:

Enables dynamic conditional visibility toggling. The parameter is rendered in the inspector only when all conditions in the expression evaluate to true.

  • Single equality: "Action=UPDATE"
  • Multiple allowed values (OR): "Action=INSERT|UPDATE"
  • Combined AND conditions (&): "Action=UPDATE&UpdateMode=WHERE"
  1. DefaultValue:

Specifies an initial default value set when the node is added or configured.

  1. HiddenFromPorts:

When set to true, the parameter is displayed as an inspector control in the UI panel, but no input port is created for it on the node canvas. This is ideal for mode selection dropdowns, feature toggles, and field selector pickers.

  1. IsPortFilter:

When set to true, the parameter's value (a comma-separated list of field keys, typically populated by a multi-select helper picker) acts as an active port filter. Only sibling parameters matching the selected keys are rendered as canvas input ports.

  1. PortKeyPrefix & PortLabelPrefix:

Prepend custom prefixes to canvas input ports generated by a port filter parameter. For example, setting PortKeyPrefix = "where_" and PortLabelPrefix = "WHERE " on a WhereFields parameter generates ports named where_column_name with canvas labels like WHERE column_name.

  1. TriggersPortRegeneration:

When set to true, changes to this parameter in the UI inspector immediately signal the engine to re-evaluate conditional visibilities, dynamic type mappings, and canvas ports.

  1. DynamicTypeMap:

Allows a parameter's data type (and canvas port data type) to adapt dynamically based on the value selected in a sibling parameter.

  • Format: "SelectorParamName:Value1=Type1;Value2=Type2;*=DefaultType"
  • Modbus Direct Access Example:
CSHARP
     new SourceCommandParameterDescriptor
     {
         Name = "Value",
         DisplayName = "Value",
         ValueType = SourceValueType.String,
         Required = true,
         DynamicTypeMap = "Type:Coils=Bool;Coil=Bool;Discrete Input=Bool;Discrete Inputs=Bool;*=Int"
     }
     

*(When Type parameter is set to "Coils", the canvas input port for Value switches to Bool; for registers, it switches to Int.)*

  • File Access Example:
CSHARP
     new SourceCommandParameterDescriptor
     {
         Name = "content",
         DisplayName = "Content",
         ValueType = SourceValueType.String,
         Required = true,
         DynamicTypeMap = "format:Bytes=ByteArray;*=String"
     }
     

8.4 Self-Referencing Pickers ($self:arguments) & Wildcards

Self-Referencing Parameter Picker ($self:arguments)

When a plugin needs to allow users to select from the node's own parameters (for example, choosing which table columns to include in a SET or WHERE clause), set HelperCollectionItemId = "$self:arguments" on a RuleFieldDescriptor.

When clicked, the helper button opens a picker listing the node's own argument descriptors without requiring an external IPC query to the backend:

CSHARP
// Example: Registering SET and WHERE field pickers for database table operations
new RuleFieldDescriptor
{
    TargetItemId = "dblink:table:*",
    ArgumentKey = "SetFields",
    EditorKind = RuleFieldEditorKind.CollectionHelper,
    HelperCollectionItemId = "$self:arguments", // Lists the node's own argument fields
    HelperDialogTitle = "Select SET Fields (Group 1)",
    IsMultiSelect = true,
    MultiSelectSeparator = ",",
    TriggersPortRegeneration = true,
    SelfArgumentExcludeKeys = "Action,UpdateMode,WhereOperator,WhereFields,SetFields" // Exclude internal control parameters
}
  • $self:arguments: Directs the picker to read the active node's argument descriptors.
  • SelfArgumentExcludeKeys: Comma-separated list of parameter keys to exclude from the picker list (hides internal control fields so only target data payload fields are listed).

Wildcard TargetItemId Matching

RuleFieldDescriptor.TargetItemId supports wildcard suffix matching using *. For example, TargetItemId = "dblink:table:*" matches any catalog item whose ID begins with dblink:table: (such as dblink:table:public_lidar_images or dblink:table:orders).


Below is a complete real-world example showing how a plugin configures advanced UPDATE modes (STRICT vs. WHERE clause builder) using purely declarative metadata:

Step 1: Adapter Parameter Metadata Declaration (MySourceAdapter.cs)

CSHARP
var tableParams = new List<SourceCommandParameterDescriptor>
{
    // Action selector dropdown
    new() { 
        Name = "Action", DisplayName = "Action", ValueType = SourceValueType.String, Required = true,
        AllowedValues = new List<string> { "INSERT", "UPDATE", "DELETE" },
        DefaultValue = "INSERT",
        GroupName = "Database Operation",
        HiddenFromPorts = true 
    },

    // Update Mode selector (visible only when Action=UPDATE)
    new() { 
        Name = "UpdateMode", DisplayName = "Update Mode", ValueType = SourceValueType.String, Required = false,
        AllowedValues = new List<string> { "STRICT", "WHERE" },
        DefaultValue = "STRICT",
        GroupName = "Database Operation",
        HiddenFromPorts = true,
        VisibleWhen = "Action=UPDATE",
        Notes = "STRICT requires PKEY. WHERE uses custom filter fields." 
    },

    // Group 1: Fields to SET (visible when Action=UPDATE & UpdateMode=WHERE)
    new() { 
        Name = "SetFields", DisplayName = "Fields to Set", ValueType = SourceValueType.String, Required = false,
        GroupName = "SET Clause (Group 1)",
        HiddenFromPorts = true,
        VisibleWhen = "Action=UPDATE&UpdateMode=WHERE",
        IsPortFilter = true,
        Notes = "Columns updated in SET clause." 
    },

    // Group 2: WHERE Clause Fields (visible when Action=UPDATE & UpdateMode=WHERE)
    new() { 
        Name = "WhereFields", DisplayName = "WHERE Fields", ValueType = SourceValueType.String, Required = false,
        GroupName = "WHERE Clause (Group 2)",
        HiddenFromPorts = true,
        VisibleWhen = "Action=UPDATE&UpdateMode=WHERE",
        IsPortFilter = true,
        PortKeyPrefix = "where_",
        PortLabelPrefix = "WHERE ",
        Notes = "Columns used in WHERE clause." 
    },

    // Group 2: Logical Operator (AND/OR)
    new() { 
        Name = "WhereOperator", DisplayName = "WHERE Operator", ValueType = SourceValueType.String, Required = false,
        AllowedValues = new List<string> { "AND", "OR" },
        DefaultValue = "AND",
        GroupName = "WHERE Clause (Group 2)",
        HiddenFromPorts = true,
        VisibleWhen = "Action=UPDATE&UpdateMode=WHERE" 
    }
};

// Add actual table columns
foreach (var col in discoveredColumns)
{
    tableParams.Add(new SourceCommandParameterDescriptor
    {
        Name = col.Name,
        DisplayName = col.Name,
        ValueType = col.Type,
        GroupName = "Column Values"
    });
}

Step 2: Register Rule Field Descriptors (PluginDefinition.cs)

CSHARP
public PluginRuleIntegration? RuleIntegration => new PluginRuleIntegration
{
    FieldDescriptors = new List<RuleFieldDescriptor>
    {
        // SET Fields multi-select argument picker
        new RuleFieldDescriptor
        {
            TargetItemId = "dblink:table:*",
            ArgumentKey = "SetFields",
            EditorKind = RuleFieldEditorKind.CollectionHelper,
            HelperCollectionItemId = "$self:arguments",
            HelperDialogTitle = "Select SET Fields (Group 1)",
            IsMultiSelect = true,
            MultiSelectSeparator = ",",
            TriggersPortRegeneration = true,
            SelfArgumentExcludeKeys = "Action,UpdateMode,WhereOperator,WhereFields,SetFields"
        },
        // WHERE Fields multi-select argument picker
        new RuleFieldDescriptor
        {
            TargetItemId = "dblink:table:*",
            ArgumentKey = "WhereFields",
            EditorKind = RuleFieldEditorKind.CollectionHelper,
            HelperCollectionItemId = "$self:arguments",
            HelperDialogTitle = "Select WHERE Fields (Group 2)",
            IsMultiSelect = true,
            MultiSelectSeparator = ",",
            TriggersPortRegeneration = true,
            SelfArgumentExcludeKeys = "Action,UpdateMode,WhereOperator,WhereFields,SetFields"
        }
    }
};

Step 3: What the User Sees in Uniflow UI

  1. Action = INSERT: Displays standard column value ports.
  2. Action = UPDATE & UpdateMode = STRICT: Requires PKEY binding, displays standard column ports.
  3. Action = UPDATE & UpdateMode = WHERE:
  • Inspector shows SET Clause (Group 1) header with a SetFields button (🔍). Clicking 🔍 lists all column names. Selected columns generate input ports on the canvas (e.g. filename, object_id).
  • Inspector shows WHERE Clause (Group 2) header with a WhereFields button (🔍) and WhereOperator dropdown (AND/OR). Selecting object_id generates a canvas input port labeled WHERE object_id (key where_object_id).
  • Node ports dynamically update without any hardcoded core logic!

9. Collection Field Helpers in Graph Nodes

A major feature of plugins like Avigilon and Milestone is the ability to browse live catalog items directly from the Rule Editor when configuring node arguments (e.g. picking an alarm ID or camera ID into an "Output Target" or "Execute Command" graph node).

ARCHITECTURE DIAGRAM
┌─────────────────────────────────────────────────────────────────────────────┐
│                    GRAPH NODE COLLECTION FIELD HELPER                       │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────┐
  │ Rule Node Property Panel            │
  │ Target: Output Target Action        │
  │ Item: avigilon:alarm                │
  │                                     │
  │ [AlarmId: 1042           ] [ 🔍 ] ──┼──► Click Helper Button
  └─────────────────────────────────────┘
                                           │
                                           ▼
                       ┌──────────────────────────────────────┐
                       │ CollectionItemPickerDialog           │
                       │ Title: Select Avigilon Alarm         │
                       ├──────────────────────────────────────┤
                       │ 🔍 Search alarms...                  │
                       │ ──────────────────────────────────── │
                       │ ◉ Perimeter Alarm [ID: 1042] (Active)│
                       │ ○ Server Room Door [ID: 1088] (Ok)   │
                       └──────────────────┬───────────────────┘
                                          │ Select & Confirm
                                          ▼
                Target field "AlarmId" populated with "1042"

9.1 Avigilon Plugin Definition Example

In AvigilonPluginDefinition, the plugin registers a collection helper for the AlarmId parameter of the avigilon:alarm action item:

CSHARP
public PluginRuleIntegration? RuleIntegration => new PluginRuleIntegration
{
    FieldDescriptors = new List<RuleFieldDescriptor>
    {
        new RuleFieldDescriptor
        {
            TargetItemId = "avigilon:alarm",
            ArgumentKey = "AlarmId",
            EditorKind = RuleFieldEditorKind.CollectionHelper,
            HelperCollectionItemId = "avigilon:alarms", // Catalog point that returns alarm list
            HelperDialogTitle = "Select Avigilon Alarm",
            HelperDisplayProperty = "Name",
            HelperValueProperty = "Id",
            HelperSubtitleProperty = "State"
        }
    }
};

9.2 Milestone Plugin Definition Example

The Milestone plugin registers multiple collection helpers for event types, camera sources, alarm priorities, and states:

CSHARP
public PluginRuleIntegration? RuleIntegration => new PluginRuleIntegration
{
    FieldDescriptors = new List<RuleFieldDescriptor>
    {
        // Camera Picker helper for Milestone Event Trigger
        new RuleFieldDescriptor
        {
            TargetItemId = "milestone:event:trigger",
            ArgumentKey = "Source",
            EditorKind = RuleFieldEditorKind.CollectionHelper,
            HelperCollectionItemId = "milestone:cameras",
            HelperDialogTitle = "Select Camera",
            HelperDisplayProperty = "Name",
            HelperValueProperty = "Id",
            HelperSubtitleProperty = "Description"
        },
        // Event Type helper for Milestone Event Trigger
        new RuleFieldDescriptor
        {
            TargetItemId = "milestone:event:trigger",
            ArgumentKey = "Type",
            EditorKind = RuleFieldEditorKind.CollectionHelper,
            HelperCollectionItemId = "milestone:eventTypes",
            HelperDialogTitle = "Select Event Type",
            HelperDisplayProperty = "DisplayName",
            HelperValueProperty = "Id",
            HelperSubtitleProperty = "Name"
        }
    }
};

9.3 Runtime Resolution via GetRuleIntegrationAsync

When the user selects an output target in a rule graph node, the Rule Editor must discover which fields have helper buttons (e.g. GUID pickers) and which fields trigger port regeneration. This is handled by PluginUiProvider.GetRuleIntegrationAsync, which dynamically loads the PluginRuleIntegration from the plugin's ISourcePluginDefinition at runtime.

How It Works

  1. When the user selects a plugin action in an Output Target node, RuleNodeViewModel.AddDynamicArgumentEditors() reads the targetSourceType property (e.g. "uniflow.plugin.avigilon") from the node.
  2. It calls PluginUiProvider.GetRuleIntegrationAsync(_ipcClient, targetSourceType) to load the plugin assembly and instantiate its ISourcePluginDefinition.
  3. The method reads the RuleIntegration property, which contains the list of RuleFieldDescriptor entries.
  4. For each argument field in the node's properties, the editor matches a descriptor by TargetItemId + ArgumentKey.
  5. If a matching descriptor has EditorKind == CollectionHelper, a search button (🔍) is rendered next to the text field.
CSHARP
// Inside PluginUiProvider — loads a plugin's RuleIntegration at runtime
public static async Task<PluginRuleIntegration?> GetRuleIntegrationAsync(IIpcClient? ipc, string sourceType)
{
    if (string.IsNullOrWhiteSpace(sourceType)) return null;

    var asm = await EnsureUiAssemblyLoadedAsync(ipc, sourceType);
    if (asm == null) return null;

    // Find the ISourcePluginDefinition type and read its RuleIntegration property
    var pluginDefType = asm.GetTypes()
        .FirstOrDefault(t => typeof(ISourcePluginDefinition).IsAssignableFrom(t) && !t.IsAbstract);

    if (pluginDefType != null)
    {
        var instance = Activator.CreateInstance(pluginDefType) as ISourcePluginDefinition;
        return instance?.RuleIntegration;
    }
    return null;
}

How CollectionItemPickerDialog Is Constructed & Populated

When the user clicks the 🔍 helper button:

  1. Instantiation: RuleNodeViewModel.OpenCollectionHelperAsync resolves the active source instance ID (sourceId) from the node and creates a CollectionItemPickerViewModel:
CSHARP
   var vm = new CollectionItemPickerViewModel(
       ipcClient, sourceId, 
       collectionItemId: descriptor.HelperCollectionItemId,
       title: descriptor.HelperDialogTitle ?? "Select Item",
       displayProperty: descriptor.HelperDisplayProperty ?? "Name",
       valueProperty: descriptor.HelperValueProperty ?? "Id",
       subtitleProperty: descriptor.HelperSubtitleProperty ?? "");
   
  1. IPC Data Fetch: CollectionItemPickerViewModel immediately calls _ipcClient.RefreshValuesAsync(sourceId, [collectionItemId]). The backend SourceAdapter responds with a JSON array string containing the catalog collection (e.g., [{"Id": "101", "Name": "Front Camera", "State": "Online"}]).
  2. JSON Property Reflection: The ViewModel parses the JSON array elements. For each object in the array, it dynamically reads fields matching the configured property names (case-insensitive):
  • DisplayName = value of JSON property matching HelperDisplayProperty (e.g. "Name")
  • Value = value of JSON property matching HelperValueProperty (e.g. "Id")
  • Subtitle = value of JSON property matching HelperSubtitleProperty (e.g. "State")
  1. WPF Template Binding: Each parsed JSON item is stored as a CollectionItemEntry model (DisplayName, Subtitle, Value) inside FilteredEntries. The WPF CollectionItemPickerDialog view binds ListBox.ItemsSource to FilteredEntries and renders rows using a DataTemplate:
XML
   <DataTemplate>
       <StackPanel>
           <TextBlock Text="{Binding DisplayName}" FontWeight="Bold" />
           <TextBlock Text="{Binding Subtitle}" FontSize="10" Foreground="Gray" />
       </StackPanel>
   </DataTemplate>
   

What the Collection Helper Picker Displays

When the user clicks the 🔍 helper button, the CollectionItemPickerDialog opens:

Picker ElementSourceDescription
Dialog TitleHelperDialogTitleWindow title text (e.g. "Select Avigilon Alarm")
Display ColumnHelperDisplayPropertyThe property name shown as the main label in each row (e.g. "Name" → "Perimeter Alarm")
Subtitle ColumnHelperSubtitlePropertySecondary info shown below or beside the label (e.g. "State" → "Active")
Search FilterBuilt-inFree-text filter matching against DisplayName, Subtitle, or Value
Data SourceHelperCollectionItemIdThe catalog item ID queried via IPC RefreshValuesAsync (e.g. "avigilon:alarms")

What Is Copied Upon Selection

When the user selects an entry and confirms, the picker reads the property specified by HelperValueProperty (e.g. "Id") from the selected item and writes it into the node's argument field:

ARCHITECTURE DIAGRAM
Selected Item:  { "Name": "Perimeter Alarm", "Id": "abc-123-def", "State": "Active" }
                                                      ▲
                                           HelperValueProperty = "Id"
                                                      │
                                                      ▼
                       Node argument field "AlarmId" ← "abc-123-def"
TIP

The HelperValueProperty typically maps to a GUID or unique identifier that the plugin's backend adapter uses to execute the action (e.g. trigger an alarm, request a camera snapshot). The display name is only used for visual selection — only the value property is persisted in the rule graph.

Multi-Select Collection Helpers (IsMultiSelect & MultiSelectSeparator)

When a rule argument requires selecting multiple catalog items (such as a comma-separated list of Target Camera GUIDs for an Avigilon Bookmark action), set IsMultiSelect = true:

CSHARP
new RuleFieldDescriptor
{
    TargetItemId = "avigilon:bookmark:insert",
    ArgumentKey = "CameraIds",
    EditorKind = RuleFieldEditorKind.CollectionHelper,
    HelperCollectionItemId = "avigilon:cameras",
    HelperDialogTitle = "Select Target Cameras",
    HelperDisplayProperty = "Name",
    HelperValueProperty = "Id",
    HelperSubtitleProperty = "Location",
    IsMultiSelect = true,          // Renders item checkboxes and Select All / Clear action controls
    MultiSelectSeparator = ","     // Delimiter used to join selected item values (default is ",")
}
Multi-Select Features:
  • Checkbox List UI: Renders WPF CheckBoxes for catalog items alongside Select All and Clear Selection toolbar buttons.
  • Stateful Pre-Selection: When the picker opens, existing field values (e.g., "cam-101,cam-102") are parsed against item values/display names to automatically check active items.
  • Value Aggregation: Upon confirmation, all checked item HelperValueProperty values are joined using MultiSelectSeparator and written back to the rule node property editor.

9.5 Generic 2-Column Parent/Child Helpers (TwoColumnHelper)

When a rule argument requires selecting a child entity under a parent catalog item (such as selecting a camera and one of its PTZ presets, a server and one of its channels, or a device and a point ID), set EditorKind = RuleFieldEditorKind.TwoColumnHelper.

CSHARP
new RuleFieldDescriptor
{
    TargetItemId = "avigilon:camera:commands:preset",
    ArgumentKey = "Id",
    EditorKind = RuleFieldEditorKind.TwoColumnHelper,
    HelperCollectionItemId = "avigilon:cameras",         // Parent collection item ID
    HelperDialogTitle = "Select PTZ Preset",             // Modal window title
    HelperColumn1Title = "Select Camera",                 // Column 1 header
    HelperColumn2Title = "Select PTZ Preset",             // Column 2 header
    HelperDisplayProperty = "Name",                       // Parent display property
    HelperValueProperty = "Id",                           // Parent value property
    HelperSubtitleProperty = "Location",                 // Parent subtitle property
    HelperChildCollectionProperty = "ptzInfo.presets",   // Nested dot-notation child array path
    HelperChildDisplayProperty = "Name",                  // Child display property
    HelperChildValueProperty = "Id",                      // Child value property
    HelperParentArgumentKey = "Id",                       // Target node parameter key for parent ID
    HelperChildArgumentKey = "PresetId"                   // Target node parameter key for child ID
}
2-Column Helper Features:
  • Parent/Child Navigation: Column 1 renders the parent collection with real-time search filtering. Selecting a parent item populates Column 2 with its child array.
  • Dual-Parameter Binding: Confirming selection automatically writes the parent ID into HelperParentArgumentKey and the child ID into HelperChildArgumentKey.
  • Nested JSON Traversal: Supports dot-notation paths (e.g., "ptzInfo.presets" or "device.channels") with automatic case-insensitive property resolution.

10. Publishing Events & Event Input Nodes

For event-driven plugins (e.g. Socket.IO streams, webhook receivers, SIA alarm signals, AI vision alerts, or MQTT event topics), plugins expose structured event streams to Event Input rule nodes.

Uniflow features a metadata-driven event system: plugins publish available events and their schema fields into the catalog, and the visual Rule Editor automatically adapts, exposing friendly event names and typed output ports.


10.1 Publishing Events to the Catalog (SourceItemKind.Event)

Plugins declare available events in OnBrowseCatalogAsync by returning SourceItemDescriptor instances with Kind = SourceItemKind.Event.

CSHARP
protected override Task<SourceCatalogSnapshot> OnBrowseCatalogAsync(bool forceRefresh, CancellationToken ct)
{
    var catalog = BuildEmptyCatalogSnapshot();

    // 1. Declare an event descriptor
    catalog.Items.Add(new SourceItemDescriptor
    {
        Id = "event:camera_intrusion",                     // Technical event identifier / subscription type
        DisplayName = "Camera Intrusion Detection",       // Human-friendly event name
        Path = "Events/Security/Intrusion",
        Category = "Security Events",
        Kind = SourceItemKind.Event,                      // Marks this item as an event
        Access = SourceItemAccess.Read,
        ValueType = SourceValueType.Json,
        IsBrowsable = true,
        Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            // Event family categorization (e.g. "EVENT", "SYSTEM", "OBJECT_DELTA", "CATALOG_IDS")
            ["event.family"] = "EVENT",

            // Schema fields exposed by event payloads: "FieldName:DataType;..."
            ["graph.fields"] = "AlarmId:string;CameraName:string;Zone:string;Confidence:double;Timestamp:datetime;Snapshot:string"
        }
    });

    return Task.FromResult(catalog);
}

Descriptor Properties for Events

PropertyTypeDescription
IdstringTechnical Event Identifier: The unique subscription key or event code (e.g. event:preset_e769257b, ANALYTICS_RULE_MATCH, sia:adm_cid:1130). Used internally for IPC event subscriptions and runtime matching.
DisplayNamestringFriendly Event Name: The human-readable title (e.g. "Camera Intrusion Detection", "Alarm Triggered"). This friendly name is displayed across the Uniflow UI: in the event picker dialog, in the Rule Editor graph node subtitle (Source / Event Name), and in the node property inspector.
KindSourceItemKindMust be set to SourceItemKind.Event. This separates events from readable data points and ensures they appear exclusively in Event Input nodes.
Metadata["event.family"]string*(Optional)* Family classification for event routing (e.g. EVENT, SYSTEM, OBJECT_DELTA, CATALOG_IDS, RULE_ACTION_CLIENT). Defaults to "EVENT".
Metadata["graph.fields"]stringSemicolon-delimited list of typed fields ("Name:Type;...") present in the event payload. Supported types include string, int, long, double, bool, datetime, json.

10.2 Dedicated Schema Providers (IEventSchemaProvider)

For plugins requiring dynamic schema definitions or complex nested event discovery (such as Avigilon ACC or Milestone XProtect), plugins can implement the IEventSchemaProvider interface (UniflowLibs.Contracts.Plugins):

CSHARP
using UniflowLibs.Contracts.Plugins;

public class MyEventSchemaProvider : IEventSchemaProvider
{
    // 1. Return available event type identifiers
    public IReadOnlyList<string> GetEventTypes() => new[]
    {
        "ALARM_TRIGGERED",
        "MOTION_DETECTED",
        "DEVICE_DISCONNECTED"
    };

    // 2. Return typed schema fields for the specified event type
    public IReadOnlyList<EventFieldDescriptor> GetEventFields(string eventType)
    {
        return eventType switch
        {
            "ALARM_TRIGGERED" => new List<EventFieldDescriptor>
            {
                new() { Name = "AlarmId", DisplayName = "Alarm ID", DataType = GraphPortDataType.String },
                new() { Name = "Priority", DisplayName = "Priority", DataType = GraphPortDataType.Int32 },
                new() { Name = "State", DisplayName = "State", DataType = GraphPortDataType.String },
                new() { Name = "Timestamp", DisplayName = "Timestamp", DataType = GraphPortDataType.DateTime }
            },
            _ => new List<EventFieldDescriptor>
            {
                new() { Name = "Payload", DisplayName = "Payload", DataType = GraphPortDataType.String }
            }
        };
    }

    // 3. Return the event family classification
    public string GetEventFamily(string eventType)
    {
        if (eventType.StartsWith("SYSTEM_")) return "SYSTEM";
        return "EVENT";
    }
}

10.3 Event Input Configuration in Rule Graphs (EventPickerDialog)

When an Event Input node is placed on the Rule Editor canvas, clicking Configure Event... opens the EventPickerDialog:

ARCHITECTURE DIAGRAM
┌─────────────────────────────────────────────────────────────────────────────┐
│                       EVENT PICKER & SCHEMA VALIDATION                      │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────────────────────────────┐
  │ EventPickerDialog                                                       │
  ├─────────────────────────────────────────────────────────────────────────┤
  │ 1. Select Event Source: [ Security Camera Endpoint              ▼ ]     │
  │ 2. Select Event:        [ Camera Intrusion Detection            ▼ ]     │
  │                         (Technical ID: event:camera_intrusion)          │
  │                                                                         │
  │ 3. Select Event Schema Fields to Expose as Output Ports:                │
  │    [x] AlarmId (String)                                                 │
  │    [x] CameraName (String)                                              │
  │    [x] Zone (String)                                                    │
  │    [x] Confidence (Double)                                              │
  │    [x] Timestamp (DateTime)                                             │
  │    [ ] Snapshot (String)                                                │
  └────────────────────────────────────┬────────────────────────────────────┘
                                       │ Confirm & Save
                                       ▼
  ┌─────────────────────────────────────────────────────────────────────────┐
  │ Rule Graph Node: Event Input                                            │
  │ Title:    Event Input                                                   │
  │ Subtitle: Security Camera Endpoint / Camera Intrusion Detection         │
  │                                                                         │
  │ Output Ports:                                                           │
  │   [●] Trigger (Bool)                                                    │
  │   [●] AlarmId (String)                                                  │
  │   [●] CameraName (String)                                               │
  │   [●] Zone (String)                                                     │
  │   [●] Confidence (Double)                                               │
  │   [●] Timestamp (DateTime)                                              │
  └─────────────────────────────────────────────────────────────────────────┘

Visual Display in the Rule Editor

  • Node Subtitle: Renders {Source.Name} /{EventDisplayName} using the friendly event title rather than internal metadata identifiers or raw GUIDs (e.g. OpenRouter / Security Camera Incident Preset rather than OpenRouter / event:preset_e769257b-9371-4ee7-ad71-aa5bf5ff2ccb).
  • Property Inspector: Displays:
  • Event Name: The human-friendly display name (eventDisplayName).
  • Event ID: The internal raw event type identifier (eventType), displayed when distinct from the display name.
  • Selection: Semicolon-separated list of active event fields mapped to output ports.

10.4 Dynamic Output Port Generation (RebuildEventPorts)

When event configuration is confirmed, the node dynamically reconstructs its output ports to expose the selected schema properties:

CSHARP
/// <summary>
/// Rebuilds output ports on an EventInput rule graph node matching selected schema fields.
/// </summary>
/// <param name="selectedFields">List of schema fields selected in the EventPickerDialog.</param>
private void RebuildEventPorts(IReadOnlyList<EventFieldDescriptor> selectedFields)
{
    Dto.Ports.Clear();

    // 1. Primary boolean trigger pulse
    Dto.Ports.Add(CreatePort("trigger", "Trigger", GraphPortDirection.Output, GraphPortDataType.Bool, true));

    // 2. Typed schema payload ports
    foreach (var field in selectedFields)
    {
        Dto.Ports.Add(CreatePort(field.Name, field.DisplayName, GraphPortDirection.Output, field.DataType, true));
    }
}

This ensures downstream rule nodes (e.g. Filter, Operator, TargetWriteAction) can connect directly to typed, structured event data outputs.


10.5 Runtime Event Dispatching

When an event occurs in the plugin adapter, dispatch it to the Uniflow Event Hub / Source Manager using NotifyEventAsync or the event subscription pipeline:

CSHARP
var payload = new Dictionary<string, object>
{
    ["AlarmId"] = Guid.NewGuid().ToString(),
    ["CameraName"] = "North Gate PTZ",
    ["Zone"] = "Perimeter West",
    ["Confidence"] = 0.94,
    ["Timestamp"] = DateTime.UtcNow
};

await NotifyEventAsync(new SourceEventMessage
{
    SourceId = SourceId,
    EventType = "event:camera_intrusion",
    EventFamily = "EVENT",
    Timestamp = DateTimeOffset.UtcNow,
    Payload = payload
}, cancellationToken);

11. Plugin Logging & Diagnostics (IUniflowLogger)

Uniflow provides a centralized, real-time logging infrastructure through the IUniflowLogger interface (UniflowLibs.Logging). Plugins write diagnostic logs, warnings, protocol handshakes, and errors directly to the host logger, enabling developers and system integrators to inspect and debug plugin activity directly within the Uniflow GUI Logs screen and debug console.

11.1 How Plugin Logging Works

ARCHITECTURE DIAGRAM
┌─────────────────────────────────────────────────────────────────────────────┐
│                    UNIFLOW PLUGIN REAL-TIME LOGGING PIPELINE                │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────┐
  │ MyCustomSourceAdapter (Plugin ALC)               │
  │  _log.Info("Sockets", "Connected to server");   │
  │  _log.Error("Polling", ex, "Read failed");      │
  └────────────────────────┬────────────────────────┘
                           │ Dispatches IUniflowLogger calls
                           ▼
  ┌─────────────────────────────────────────────────┐
  │ UniflowService Logging Subsystem                │
  │  - AppLogFacadeAdapter / LocalLogger            │
  │  - Formats timestamps, region tags, & levels    │
  └────────────────────────┬────────────────────────┘
                           │ Streams over Named Pipe IPC
                           ▼
  ┌─────────────────────────────────────────────────┐
  │ Uniflow GUI (Client Desktop App)                │
  │  - Live Log Viewer & Inspector Console          │
  │  - Color-coded log entries by verbosity & region│
  └─────────────────────────────────────────────────┘
  1. Dependency Injection: When UniflowService instantiates a plugin's ISourceAdapterFactory, it passes the host IServiceProvider. The factory resolves IUniflowLogger using (IUniflowLogger?)sp.GetService(typeof(IUniflowLogger)) ?? NullUniflowLogger.Instance.
  2. Region Scoping: Adapters create sub-loggers scoped to a specific region using _log = log?.ForRegion("MyPluginName") ?? NullUniflowLogger.Instance;. You can also create nested sub-regions (e.g. log.ForRegion("MyPlugin.Sockets")).
  3. IPC Real-Time Streaming: Log messages emitted by the plugin are captured by UniflowService, recorded to log files on disk, and streamed in real time over Named Pipe IPC to the Uniflow GUI Log Viewer.
  4. Fallback Null Pattern: Using NullUniflowLogger.Instance as a default fallback ensures that plugin code never throws NullReferenceException when instantiated in isolated unit test environments without a host logger container.

11.2 Logging Methods & Verbosity Levels

IUniflowLogger supports standard logging methods with custom category tags:

CSHARP
// Information: Routine lifecycle events, connection status changes
_log.Info("Lifecycle", $"Connected to host {_host}:{_port}");

// Debug: Detailed diagnostic traces, packet dumps, catalog updates
_log.Debug("Diagnostics", $"Polled 14 items in {sw.ElapsedMilliseconds} ms");

// Warning: Recoverable issues, retries, non-critical protocol warnings
_log.Warn("Protocol", $"Transient timeout receiving frame. Retrying (attempt {retryCount})...");

// Error: Unhandled exceptions, failed connections, data parse errors
_log.Error("Sockets", ex, $"Failed to connect to hardware endpoint at {_host}:{_port}");
TIP

Verbosity Filtering: Integrators can adjust the global log verbosity level (UniflowLogVerbosity.Info, Debug, Trace, Full) from the Uniflow GUI Settings screen. _log.Verbosity reflects the active threshold at runtime.

12. Dynamic Remote UI Pipeline Details

When configuring a source in the UI, Uniflow uses the following remote delivery workflow:

ARCHITECTURE DIAGRAM
┌──────────────┐                  ┌─────────────────┐                 ┌──────────────────────┐
│  Uniflow GUI │                  │ Uniflow IPC Pipe│                 │    UniflowService    │
└──────┬───────┘                  └────────┬────────┘                 └──────────┬───────────┘
       │                                   │                                     │
       │ GetPluginUiBundle(SourceType)     │                                     │
       ├──────────────────────────────────►│                                     │
       │                                   │  GetPluginUiBundleRequest           │
       │                                   ├────────────────────────────────────►│
       │                                   │                                     │ 1. Read assembly & deps
       │                                   │                                     │ 2. Read PDBs
       │                                   │  GetPluginUiBundleResponse          │ 3. Build UI Bundle DTO
       │                                   │◄────────────────────────────────────┤
       │ Response (UiAssemblyBytes + Deps) │                                     │
       │◄──────────────────────────────────┤                                     │
       │                                                                         │
 4. Load into ClientPluginSessionManager ALC                                     │
 5. PluginUiProvider resolves *EditorView & *EditorViewModel                     │
 6. Render WPF UserControl in Settings modal                                     │
  1. GUI Request: PluginUiProvider.EnsureUiAssemblyLoadedAsync sends IpcCommand.GetPluginUiBundle with PluginId = sourceType.
  2. Service Retrieval: UniflowService reads the plugin package, extracts the compiled assembly DLL, PDBs, and dependent assemblies, and sends them in GetPluginUiBundleResponse.
  3. Client Session Load: ClientPluginSessionManager creates an isolated PluginSessionLoadContext (a collectible AssemblyLoadContext) on the GUI client side and loads the UI assembly bytes.
  4. View & ViewModel Resolution: PluginUiProvider reflects over the loaded assembly, finds MyCustomSourceEditorView and MyCustomSourceEditorViewModel, instantiates the ViewModel injecting IIpcClient, SourceDto, and sourceType, and binds it to the WPF View control.

13. Packaging and Deployment

Plugins are packaged as .zip archives containing plugin.json, the entry .dll, icon.png, and any external third-party dependencies.

13.1 ZIP Package Layout

TEXT
Uniflow.Plugin.MyCustomPlugin.zip
  ├── plugin.json
  ├── Uniflow.Plugin.MyCustomPlugin.dll
  ├── icon.png
  └── System.IO.Ports.dll (Optional third-party dependency)

13.2 Local Installation Path

Extract the bundle into the service plugin directory:

C:\ProgramData\Uniflow\Plugins\uniflow.plugin.mycustomplugin\

13.3 Graphical Installation (UI)

  1. Launch Uniflow GUI.
  2. Open Settings -> Plugins.
  3. Click Upload Plugin (.zip) (or install from online repository).
  4. Uniflow GUI sends an InstallPlugin IPC request to UniflowService.
  5. UniflowService validates the package manifest, extracts the bundle, instantiates the plugin adapter in an isolated ALC sandbox, and pushes the available plugin metadata to the UI.

14. Verification & Diagnostics Checklist

To ensure your custom plugin functions reliably in production:

  • [x] Verify Service Load: Check Logs screen for [PluginManager] Loaded plugin 'My Custom Protocol' (v1.0.0).
  • [x] Verify Real-Time Logging: Check Logs screen for plugin log entries tagged with your plugin region name (e.g. [MyCustomPlugin] Connected to 127.0.0.1:5000).
  • [x] Verify Remote UI Fetch: Confirm in UI when opening source editor that GetPluginUiBundle succeeds and the custom WPF control renders cleanly.
  • [x] Verify Source Editor Helper Windows: Open custom topic/catalog configuration dialogs (e.g. Avigilon Topics Dialog) and verify atomic topic selection and CSV string building.
  • [x] Verify Graph Collection Helpers: Open Rules Editor, add an Output Target node targeting a plugin action, click the helper icon (🔍), and confirm the picker populates values (e.g. Alarm IDs, Camera GUIDs).
  • [x] Verify Event Input Navigation: Add an Event Input node, open EventPickerDialog, select exposed event types/fields, and verify output ports generate correctly.
  • [x] Test Hot Unload: Use Plugin Manager to uninstall or update the plugin while the service is running. Verify that both service ALC and client ClientPluginSessionManager ALC are garbage collected.
Architecture Flow Diagram — Full Preview