feat(adminui): DriverTagPicker modal + 9 static address builders

- DriverTagPicker shell: modal chrome + per-driver picker body
  rendered as ChildContent.
- 9 picker bodies (Modbus/AbCip/AbLegacy/S7/TwinCat/FOCAS/
  OpcUaClient/Galaxy/Historian.Wonderware). 5 have computed
  builder logic + unit tests; 4 are free-text passthroughs
  (live browse for OPC UA + Galaxy is a documented follow-up).
- Each typed driver page gets a "Pick address" button that opens
  the modal with the matching body. Picked address surfaces in
  the modal footer for manual copy — no JS interop in v1.
This commit is contained in:
Joseph Doherty
2026-05-28 11:21:33 -04:00
parent ffcc8d1065
commit 063005fefa
29 changed files with 873 additions and 0 deletions
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB CIP address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation timeout *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
@@ -183,6 +196,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Collections are preserved through round-trip and shown as read-only JSON.
private IReadOnlyList<AbCipDeviceOptions> _devices = [];
private IReadOnlyList<AbCipTagDefinition> _tags = [];
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
@@ -47,8 +48,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB Legacy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbLegacyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation settings *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
@@ -152,6 +165,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Collections are preserved through round-trip and shown as read-only JSON.
private IReadOnlyList<AbLegacyDeviceOptions> _devices = [];
private IReadOnlyList<AbLegacyTagDefinition> _tags = [];
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="FOCAS address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
@@ -241,6 +254,12 @@ else
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Galaxy.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Galaxy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* mxaccessgw connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">mxaccessgw connection</div>
@@ -213,6 +226,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Historian.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Historian Wonderware address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<HistorianWonderwareAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Connection</div>
@@ -145,6 +158,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Modbus address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Transport *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Transport</div>
@@ -298,6 +311,13 @@ else
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — rendered as read-only JSON.
private IReadOnlyList<ModbusTagDefinition> _tags = [];
private string _tagsJson = "[]";
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.OpcUa.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="OPC UA address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Endpoint *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Endpoint</div>
@@ -261,6 +274,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Read-only JSON snippets for collections that have no list editor yet.
private string _endpointUrlsJson = "[]";
private string _unsMappingTableJson = "{}";
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.S7
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="S7 address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<S7AddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
@@ -177,6 +190,12 @@ else
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -5,6 +5,7 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
@@ -46,8 +47,20 @@ else
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="TwinCAT address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Options *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Options</div>
@@ -183,6 +196,12 @@ else
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -0,0 +1,52 @@
@* Shared modal shell for per-driver address pickers. Parent page toggles `Visible`;
the child fragment renders the per-driver picker body. The shell handles modal
chrome, the "Use this address" button, and dismisses on close. *@
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="OnCloseAsync"></button>
</div>
<div class="modal-body">
@ChildContent
</div>
<div class="modal-footer">
@if (!string.IsNullOrEmpty(CurrentAddress))
{
<code class="me-auto mono">@CurrentAddress</code>
}
<button type="button" class="btn btn-outline-secondary" @onclick="OnCloseAsync">Close</button>
<button type="button" class="btn btn-primary" disabled="@string.IsNullOrEmpty(CurrentAddress)"
@onclick="OnUseAsync">
Use this address
</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
[Parameter] public string Title { get; set; } = "Address builder";
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> OnPickAddress { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
private async Task OnCloseAsync()
{
await VisibleChanged.InvokeAsync(false);
}
private async Task OnUseAsync()
{
await OnPickAddress.InvokeAsync(CurrentAddress);
await VisibleChanged.InvokeAsync(false);
}
}
@@ -0,0 +1,57 @@
@* Static AB CIP address builder: tag name + optional element index → tag[idx] or tag *@
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="Program:Main.MyTag"
@bind="_tagName" @bind:after="OnChangedAsync" />
<div class="form-text">Use dot notation for nested tags or UDT members.</div>
</div>
<div class="col-md-3">
<label class="form-label">Element index (optional)</label>
<input type="number" class="form-control form-control-sm" min="0"
@bind="_elementIndex" @bind:after="OnChangedAsync" />
<div class="form-text">Leave 0 for non-array tags (no index appended).</div>
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="abCipUseIdx"
@bind="_useIndex" @bind:after="OnChangedAsync" />
<label class="form-check-label" for="abCipUseIdx">Append index</label>
</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _tagName = "";
private int _elementIndex = 0;
private bool _useIndex = false;
private string _built = "";
protected override void OnInitialized()
{
_built = Build();
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
private string Build()
{
if (string.IsNullOrWhiteSpace(_tagName))
return "";
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts AB Legacy PLC file-type + file-number + element
/// into the canonical address string (e.g. N7:0).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class AbLegacyAddressBuilder
{
public static string Build(string fileType, int fileNumber, int element)
=> $"{fileType}{fileNumber}:{element}";
}
@@ -0,0 +1,58 @@
@* Static AB Legacy address builder: file type + file number + element → N7:0 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">File type</label>
<select class="form-select form-select-sm" @bind="_fileType" @bind:after="OnChangedAsync">
<option value="N">N — Integer</option>
<option value="B">B — Binary/Bit</option>
<option value="F">F — Float</option>
<option value="I">I — Input</option>
<option value="O">O — Output</option>
<option value="S">S — Status</option>
<option value="T">T — Timer</option>
<option value="C">C — Counter</option>
<option value="R">R — Control</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">File number</label>
<input type="number" class="form-control form-control-sm" min="0" max="999"
@bind="_fileNumber" @bind:after="OnChangedAsync" />
<div class="form-text">e.g. 7 for N7</div>
</div>
<div class="col-md-3">
<label class="form-label">Element</label>
<input type="number" class="form-control form-control-sm" min="0" max="9999"
@bind="_element" @bind:after="OnChangedAsync" />
<div class="form-text">e.g. 0 for N7:0</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _fileType = "N";
private int _fileNumber = 7;
private int _element = 0;
private string _built = "";
protected override void OnInitialized()
{
_built = AbLegacyAddressBuilder.Build(_fileType, _fileNumber, _element);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = AbLegacyAddressBuilder.Build(_fileType, _fileNumber, _element);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,45 @@
@* Static FOCAS address builder: parameter group + parameter ID → axis:5 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Parameter group</label>
<select class="form-select form-select-sm" @bind="_group" @bind:after="OnChangedAsync">
<option value="axis">axis</option>
<option value="spindle">spindle</option>
<option value="program">program</option>
<option value="status">status</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Parameter ID</label>
<input type="number" class="form-control form-control-sm" min="0"
@bind="_parameterId" @bind:after="OnChangedAsync" />
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _group = "axis";
private int _parameterId = 0;
private string _built = "";
protected override void OnInitialized()
{
_built = FocasAddressBuilder.Build(_group, _parameterId);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = FocasAddressBuilder.Build(_group, _parameterId);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts a FOCAS parameter group + parameter ID
/// into the canonical address string (e.g. axis:5).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class FocasAddressBuilder
{
public static string Build(string group, int parameterId)
=> $"{group}:{parameterId}";
}
@@ -0,0 +1,57 @@
@* Static Galaxy address builder: tag_name.AttributeName free text → verbatim.
Live Galaxy browse deferred to a follow-up phase. *@
<div class="alert alert-info py-2 px-3 mb-3 small">
<strong>Note:</strong> Live Galaxy browse is deferred to a follow-up phase.
Enter the tag and attribute name manually below.
</div>
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="DelmiaReceiver_001"
@bind="_tagName" @bind:after="OnChangedAsync" />
<div class="form-text">Globally unique system (tag) name.</div>
</div>
<div class="col-md-5">
<label class="form-label">Attribute name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="DownloadPath"
@bind="_attributeName" @bind:after="OnChangedAsync" />
<div class="form-text">MXAccess attribute name.</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _tagName = "";
private string _attributeName = "";
private string _built = "";
protected override void OnInitialized()
{
_built = Build();
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
private string Build()
{
if (string.IsNullOrWhiteSpace(_tagName))
return "";
if (string.IsNullOrWhiteSpace(_attributeName))
return _tagName;
return $"{_tagName}.{_attributeName}";
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts a Wonderware Historian tag name + retrieval mode
/// + interval into the canonical address query string (e.g. MyTag?mode=Cyclic&amp;interval=60).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class HistorianWonderwareAddressBuilder
{
public static string Build(string tagName, string mode, int interval)
=> $"{tagName}?mode={mode}&interval={interval}";
}
@@ -0,0 +1,52 @@
@* Static Wonderware Historian address builder: tag name + retrieval mode + interval
→ MyTag?mode=Cyclic&interval=60 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="SysTimeHour"
@bind="_tagName" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-3">
<label class="form-label">Retrieval mode</label>
<select class="form-select form-select-sm" @bind="_mode" @bind:after="OnChangedAsync">
<option value="Last">Last</option>
<option value="Cyclic">Cyclic</option>
<option value="Delta">Delta</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<input type="number" class="form-control form-control-sm" min="1"
@bind="_interval" @bind:after="OnChangedAsync" />
<div class="form-text">Polling/retrieval interval.</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _tagName = "";
private string _mode = "Cyclic";
private int _interval = 60;
private string _built = "";
protected override void OnInitialized()
{
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts Modbus register-type + offset + length into
/// the canonical address string used by the Modbus driver (e.g. 4x00001-1).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class ModbusAddressBuilder
{
public static string Build(string regType, int offset, int length)
{
var prefix = regType switch
{
"Coil" => "0x",
"DiscreteInput" => "1x",
"Input" => "3x",
"Holding" => "4x",
_ => "4x",
};
return $"{prefix}{offset:00000}-{length}";
}
}
@@ -0,0 +1,51 @@
@* Static Modbus address builder: register type + offset + length → 4x00001-4 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Register type</label>
<select class="form-select form-select-sm" @bind="_regType" @bind:after="OnChangedAsync">
<option value="Coil">Coil (0x)</option>
<option value="DiscreteInput">DiscreteInput (1x)</option>
<option value="Input">Input (3x)</option>
<option value="Holding">Holding (4x)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Offset</label>
<input type="number" class="form-control form-control-sm" min="0" max="99999"
@bind="_offset" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-3">
<label class="form-label">Length</label>
<input type="number" class="form-control form-control-sm" min="1" max="125"
@bind="_length" @bind:after="OnChangedAsync" />
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _regType = "Holding";
private int _offset = 1;
private int _length = 1;
private string _built = "";
protected override void OnInitialized()
{
_built = ModbusAddressBuilder.Build(_regType, _offset, _length);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = ModbusAddressBuilder.Build(_regType, _offset, _length);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,43 @@
@* Static OPC UA Client address builder: NodeId free text → verbatim.
Live browse deferred to a follow-up phase. *@
<div class="alert alert-info py-2 px-3 mb-3 small">
<strong>Note:</strong> Live OPC UA node browse is deferred to a follow-up phase.
Enter the NodeId string manually below.
</div>
<div class="row g-3">
<div class="col-md-10">
<label class="form-label">NodeId</label>
<input type="text" class="form-control form-control-sm mono" placeholder="ns=2;s=Channel.Device.Tag"
@bind="_nodeId" @bind:after="OnChangedAsync" />
<div class="form-text">
OPC UA NodeId string, e.g. <code>ns=2;s=Channel.Device.Tag</code> or <code>i=1001</code>.
</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _nodeId = "";
private string _built = "";
protected override void OnInitialized()
{
_built = _nodeId;
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = _nodeId;
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts S7 area + db-number + offset + data-type
/// into the canonical S7 address string (e.g. DB10.DBD20:REAL, M0.0:X).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class S7AddressBuilder
{
/// <param name="area">DB / M / I / Q</param>
/// <param name="dbNumber">Only relevant when area == "DB".</param>
/// <param name="offset">Byte offset (decimal).</param>
/// <param name="s7Type">X / B / W / D / REAL</param>
public static string Build(string area, int dbNumber, int offset, string s7Type)
{
if (area == "DB")
{
// e.g. DB10.DBD20:REAL / DB1.DBX0.0:X / DB5.DBW4:W
var qualifier = s7Type switch
{
"X" => $"DBX{offset}.0",
"B" => $"DBB{offset}",
"W" => $"DBW{offset}",
"D" => $"DBD{offset}",
"REAL" => $"DBD{offset}",
_ => $"DBD{offset}",
};
return $"DB{dbNumber}.{qualifier}:{s7Type}";
}
else
{
// e.g. M0.0:X / I4:B / Q2:W
var offsetStr = s7Type == "X" ? $"{offset}.0" : $"{offset}";
return $"{area}{offsetStr}:{s7Type}";
}
}
}
@@ -0,0 +1,65 @@
@* Static S7 address builder: area + db-number + offset + type → DB10.DBD20:REAL / M0.0:X *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-2">
<label class="form-label">Area</label>
<select class="form-select form-select-sm" @bind="_area" @bind:after="OnChangedAsync">
<option value="DB">DB — Data Block</option>
<option value="M">M — Merker</option>
<option value="I">I — Input</option>
<option value="Q">Q — Output</option>
</select>
</div>
@if (_area == "DB")
{
<div class="col-md-2">
<label class="form-label">DB number</label>
<input type="number" class="form-control form-control-sm" min="1" max="65535"
@bind="_dbNumber" @bind:after="OnChangedAsync" />
</div>
}
<div class="col-md-2">
<label class="form-label">Offset (bytes)</label>
<input type="number" class="form-control form-control-sm" min="0" max="65535"
@bind="_offset" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-2">
<label class="form-label">S7 type</label>
<select class="form-select form-select-sm" @bind="_s7Type" @bind:after="OnChangedAsync">
<option value="X">X — Bit</option>
<option value="B">B — Byte</option>
<option value="W">W — Word</option>
<option value="D">D — DWord</option>
<option value="REAL">REAL — Float</option>
</select>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _area = "DB";
private int _dbNumber = 1;
private int _offset = 0;
private string _s7Type = "REAL";
private string _built = "";
protected override void OnInitialized()
{
_built = S7AddressBuilder.Build(_area, _dbNumber, _offset, _s7Type);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = S7AddressBuilder.Build(_area, _dbNumber, _offset, _s7Type);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,37 @@
@* Static TwinCAT address builder: ADS variable name (free text) → verbatim *@
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">ADS variable name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="MAIN.fValue"
@bind="_varName" @bind:after="OnChangedAsync" />
<div class="form-text">
Full ADS symbol path, e.g. <code>MAIN.fValue</code> or <code>GVL.iCounter</code>.
</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _varName = "";
private string _built = "";
protected override void OnInitialized()
{
_built = _varName;
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = _varName;
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,17 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class AbLegacyAddressBuilderTests
{
[Theory]
[InlineData("N", 7, 0, "N7:0")]
[InlineData("B", 3, 1, "B3:1")]
[InlineData("F", 8, 12, "F8:12")]
[InlineData("T", 4, 0, "T4:0")]
[InlineData("C", 5, 2, "C5:2")]
public void Build_Canonical(string fileType, int fileNumber, int element, string expected)
=> AbLegacyAddressBuilder.Build(fileType, fileNumber, element).ShouldBe(expected);
}
@@ -0,0 +1,16 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class FocasAddressBuilderTests
{
[Theory]
[InlineData("axis", 5, "axis:5")]
[InlineData("spindle", 0, "spindle:0")]
[InlineData("program", 100, "program:100")]
[InlineData("status", 1, "status:1")]
public void Build_Canonical(string group, int parameterId, string expected)
=> FocasAddressBuilder.Build(group, parameterId).ShouldBe(expected);
}
@@ -0,0 +1,15 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class HistorianWonderwareAddressBuilderTests
{
[Theory]
[InlineData("SysTimeHour", "Cyclic", 60, "SysTimeHour?mode=Cyclic&interval=60")]
[InlineData("ReactorTemp", "Last", 1, "ReactorTemp?mode=Last&interval=1")]
[InlineData("FlowRate", "Delta", 30, "FlowRate?mode=Delta&interval=30")]
public void Build_Canonical(string tag, string mode, int interval, string expected)
=> HistorianWonderwareAddressBuilder.Build(tag, mode, interval).ShouldBe(expected);
}
@@ -0,0 +1,21 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class ModbusAddressBuilderTests
{
[Theory]
[InlineData("Holding", 1, 1, "4x00001-1")]
[InlineData("Coil", 0, 1, "0x00000-1")]
[InlineData("Holding", 123, 4, "4x00123-4")]
[InlineData("DiscreteInput", 5, 1, "1x00005-1")]
[InlineData("Input", 99999, 125, "3x99999-125")]
public void Build_Canonical(string type, int offset, int length, string expected)
=> ModbusAddressBuilder.Build(type, offset, length).ShouldBe(expected);
[Fact]
public void Build_UnknownType_FallsBackToHolding()
=> ModbusAddressBuilder.Build("Unknown", 1, 1).ShouldBe("4x00001-1");
}
@@ -0,0 +1,20 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class S7AddressBuilderTests
{
[Theory]
[InlineData("DB", 10, 20, "REAL", "DB10.DBD20:REAL")]
[InlineData("DB", 1, 0, "X", "DB1.DBX0.0:X")]
[InlineData("DB", 5, 4, "W", "DB5.DBW4:W")]
[InlineData("DB", 2, 6, "B", "DB2.DBB6:B")]
[InlineData("M", 1, 0, "X", "M0.0:X")]
[InlineData("M", 1, 4, "W", "M4:W")]
[InlineData("I", 1, 0, "B", "I0:B")]
[InlineData("Q", 1, 2, "W", "Q2:W")]
public void Build_Canonical(string area, int dbNumber, int offset, string s7Type, string expected)
=> S7AddressBuilder.Build(area, dbNumber, offset, s7Type).ShouldBe(expected);
}