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
@@ -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);
}
}