feat(heidenhain): AdminUI driver/device forms, typed tag editor, CSV + browse-commit (#803)

- HeidenhainDriverForm over HeidenhainDriverFormModel (JsonObject bag preserves Devices and
  unknown keys; no enum fields; Validate asks HeidenhainOptionsParser) + DriverConfigFormMap.
- HeidenhainDeviceForm/Model (Host, Port 19000, optional per-device password references)
  + DeviceFormMap. Literal passwords are refused in both forms.
- RawDriverTypeDialog entry; HeidenhainTagConfigEditor over HeidenhainTagConfigModel, whose
  Validate is the driver's own TryFromTagConfig; catalog leaves derive dataType + isArray;
  registered in TagConfigEditorMap + TagConfigValidator.
- CsvColumnMap (HhAddress/HhDataType/HhIsArray) and RawBrowseCommitMapper branch.
- Guards: DriverPageJsonConverterTests external-serializer entry, TagConfigNonEnumParityTests
  runtime map + corpus + freshly-authored rows, TagConfigRoundTrip, HeidenhainAuthoringTests.
This commit is contained in:
Joseph Doherty
2026-11-10 23:17:26 -05:00
parent 2b45694f91
commit 3641d56154
17 changed files with 721 additions and 0 deletions
@@ -0,0 +1,76 @@
@* Embeddable Heidenhain device (one LSV2 control) form body — authors Host/Port and optional per-device
password REFERENCES into the Device's DeviceConfig (PascalCase, matching HeidenhainDeviceOptions). Hosted by
DeviceModal. Literal passwords are refused here and at the deploy gate (HeidenhainCredentialPersisted). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">LSV2 endpoint</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label" for="hhHost">Host</label>
<InputText id="hhHost" @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="10.20.30.40" />
<div class="form-text">Control hostname or IP. No default endpoint. Plain LSV2 is unencrypted — route it over an externally managed secure network.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="hhPort">Port</label>
<InputNumber id="hhPort" @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 19000.</div>
</div>
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Per-device credentials (optional overrides)</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="hhInspect">INSPECT password ref</label>
<InputText id="hhInspect" @bind-Value="_model.InspectPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:lsv2/inspect" />
</div>
<div class="col-md-4">
<label class="form-label" for="hhDnc">DNC password ref</label>
<InputText id="hhDnc" @bind-Value="_model.DncPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:lsv2/dnc" />
</div>
<div class="col-md-4">
<label class="form-label" for="hhData">DATA password ref</label>
<InputText id="hhData" @bind-Value="_model.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:LSV2_DATA_PASSWORD" />
</div>
</div>
<div class="form-text">Blank = use the driver-level reference. <span class="mono">secret:&lt;name&gt;</span> resolves from the node's secret store, <span class="mono">env:&lt;VAR&gt;</span> from the environment, at each login. A literal password is never stored.</div>
@if (_model.Validate() is { } error)
{
<div class="text-danger small mt-2">@error</div>
}
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON.</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private HeidenhainDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = HeidenhainDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// The Heidenhain device (one control's endpoint) DeviceConfig, PascalCase keys matching
/// <see cref="HeidenhainDeviceOptions"/>. Unknown keys are preserved. Password fields hold REFERENCES only
/// (blank / <c>secret:</c> / <c>env:</c>); <see cref="Validate"/> uses the driver's own rule.
/// </summary>
public sealed class HeidenhainDeviceModel
{
public string Host { get; set; } = "";
public int Port { get; set; } = HeidenhainDriverOptions.DefaultPort;
public string? InspectPassword { get; set; }
public string? DncPassword { get; set; }
public string? DataPassword { get; set; }
private System.Text.Json.Nodes.JsonObject _bag = new();
public static HeidenhainDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new HeidenhainDeviceModel
{
Host = TagConfigJson.GetString(o, "Host") ?? "",
Port = TagConfigJson.GetInt(o, "Port", HeidenhainDriverOptions.DefaultPort),
InspectPassword = TagConfigJson.GetString(o, "InspectPassword"),
DncPassword = TagConfigJson.GetString(o, "DncPassword"),
DataPassword = TagConfigJson.GetString(o, "DataPassword"),
_bag = o,
};
}
public string ToJson()
{
TagConfigJson.Set(_bag, "Host", Host.Trim());
TagConfigJson.Set(_bag, "Port", Port);
TagConfigJson.Set(_bag, "InspectPassword", Blank(InspectPassword));
TagConfigJson.Set(_bag, "DncPassword", Blank(DncPassword));
TagConfigJson.Set(_bag, "DataPassword", Blank(DataPassword));
return TagConfigJson.Serialize(_bag);
}
public string? Validate()
{
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList();
foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) })
if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v)))
errors.Add($"{name} password must be blank, secret:<name> or env:<VARIABLE> — never a literal.");
return errors.Count == 0 ? null : string.Join(" ", errors);
}
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
@@ -31,6 +31,7 @@ public static class DriverConfigFormMap
[DriverTypeNames.Sql] = typeof(SqlDriverForm),
[DriverTypeNames.Mqtt] = typeof(MqttDriverForm),
[DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm),
[DriverTypeNames.Heidenhain] = typeof(HeidenhainDriverForm),
[DriverTypeNames.Calculation] = typeof(CalculationDriverForm),
};
@@ -67,6 +68,7 @@ public static class DeviceFormMap
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm),
[DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm),
[DriverTypeNames.Mqtt] = typeof(MqttDeviceForm),
[DriverTypeNames.Heidenhain] = typeof(HeidenhainDeviceForm),
};
/// <summary>
@@ -0,0 +1,152 @@
@* Embeddable Heidenhain LSV2 driver config form body (lmxopcua#803). The per-control endpoint (Host/Port)
lives on HeidenhainDeviceForm (one /raw Device per control). The driver is READ-ONLY: there is no write
setting to author — the outbound LSV2 allowlist admits only logins and read requests. A thin shell over
HeidenhainDriverFormModel, which owns every rule and preserves keys it does not own (Devices). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="hhTimeout">Transaction timeout (ms)</label>
<InputNumber id="hhTimeout" @bind-Value="_m.TimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">100–60000. Bounds connect and each complete response. Default 5000.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="hhReconnect">Reconnect initial delay (ms)</label>
<InputNumber id="hhReconnect" @bind-Value="_m.ReconnectInitialMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="hhReconnectMax">Reconnect max delay (ms)</label>
<InputNumber id="hhReconnectMax" @bind-Value="_m.ReconnectMaxMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Backoff doubles from the initial delay up to this ceiling.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="hhProbe">Test Connect timeout (s)</label>
<InputNumber id="hhProbe" @bind-Value="_m.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Access (read-only roles)</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="hhTelemetry" @bind-Value="_m.TelemetryEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhTelemetry">Telemetry reads</label>
</div>
<div class="form-text">Execution/program state, program, overrides, axes, tool, alarms. Off = no runtime-info requests.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="hhDnc" @bind-Value="_m.DncLoginEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhDnc">Optional DNC login</label>
</div>
<div class="form-text">A refused DNC login never blocks reads the control permits under INSPECT.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="hhData" @bind-Value="_m.DataEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhData">DATA path reads</label>
</div>
<div class="form-text">Opt-in DATA login, only when a <span class="mono">DATA:</span> binding needs it (reference-derived; iTNC only).</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="hhInspectPw">INSPECT password ref</label>
<InputText id="hhInspectPw" @bind-Value="_m.InspectPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:lsv2/inspect" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="hhDncPw">DNC password ref</label>
<InputText id="hhDncPw" @bind-Value="_m.DncPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:lsv2/dnc" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="hhDataPw">DATA password ref</label>
<InputText id="hhDataPw" @bind-Value="_m.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:LSV2_DATA_PASSWORD" />
</div>
</div>
<div class="form-text">References only: blank, <span class="mono">secret:&lt;name&gt;</span> (node secret store) or <span class="mono">env:&lt;VAR&gt;</span>. Resolved at each login; a literal password is refused.</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Polling &amp; limits</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-2 mb-3">
<label class="form-label" for="hhStatus">Status (ms)</label>
<InputNumber id="hhStatus" @bind-Value="_m.StatusMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhCustom">Custom (ms)</label>
<InputNumber id="hhCustom" @bind-Value="_m.CustomMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhSlow">Slow (ms)</label>
<InputNumber id="hhSlow" @bind-Value="_m.SlowMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarms">Max alarms</label>
<InputNumber id="hhAlarms" @bind-Value="_m.MaxAlarms" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="hhTimers" @bind-Value="_m.TimersEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhTimers">Experimental timer reads</label>
</div>
<div class="form-text">Selectors 30–34. Units/semantics unverified on real controls — confirm before using as durations.</div>
</div>
</div>
<div class="form-text">Intervals 100–3600000 ms. OPC reads and subscriptions are served from the cache; sampling cannot exceed these cadences.</div>
@if (_m.Validate() is { } error)
{
<div class="text-danger small mt-2">@error</div>
}
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON.</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private HeidenhainDriverFormModel _m = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
_m = HeidenhainDriverFormModel.FromJson(DriverConfigJson);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current config.</summary>
public string GetConfigJson() => _m.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
}
@@ -0,0 +1,114 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
/// <summary>
/// Flat, bindable model of the Heidenhain driver-level DriverConfig. Round-trips through a JSON bag so every key
/// the form does not own (the multi-device <c>Devices</c> collection, future keys) survives a load→save. Every
/// field is an int/bool/string — there is no enum to serialize numerically — and <see cref="Validate"/> asks the
/// driver's own <see cref="HeidenhainOptionsParser"/>, so the form accepts exactly what the factory accepts.
/// </summary>
public sealed class HeidenhainDriverFormModel
{
public int TimeoutMs { get; set; } = 5000;
public int ProbeTimeoutSeconds { get; set; } = 10;
public bool TelemetryEnabled { get; set; } = true;
public bool DncLoginEnabled { get; set; } = true;
public bool DataEnabled { get; set; }
public string? InspectPassword { get; set; }
public string? DncPassword { get; set; }
public string? DataPassword { get; set; }
public int StatusMs { get; set; } = 1000;
public int CustomMs { get; set; } = 1000;
public int SlowMs { get; set; } = 10000;
public int MaxAlarms { get; set; } = 64;
public bool TimersEnabled { get; set; }
public int ReconnectInitialMs { get; set; } = 1000;
public int ReconnectMaxMs { get; set; } = 30000;
/// <summary>
/// The one serializer this form writes config through. The document carries no enum today; the string-enum
/// converter is registered anyway so a future enum field can never serialize as a number (the repo-wide
/// form/factory trap) — guarded by <c>DriverPageJsonConverterTests</c>.
/// </summary>
public static readonly System.Text.Json.JsonSerializerOptions JsonOptions = new()
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private JsonObject _bag = new();
public static HeidenhainDriverFormModel FromJson(string? json)
{
JsonObject bag;
try { bag = JsonNode.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json) as JsonObject ?? new JsonObject(); }
catch (System.Text.Json.JsonException) { bag = new JsonObject(); }
HeidenhainDriverOptions o;
try { o = HeidenhainOptionsParser.Parse(bag.ToJsonString()); }
catch (InvalidOperationException) { o = new HeidenhainDriverOptions(); }
return new HeidenhainDriverFormModel
{
TimeoutMs = o.TimeoutMs,
ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
TelemetryEnabled = o.Access.TelemetryEnabled,
DncLoginEnabled = o.Access.DncLoginEnabled,
DataEnabled = o.Access.DataEnabled,
InspectPassword = o.Access.InspectPassword,
DncPassword = o.Access.DncPassword,
DataPassword = o.Access.DataPassword,
StatusMs = o.Polling.StatusMs,
CustomMs = o.Polling.CustomMs,
SlowMs = o.Polling.SlowMs,
MaxAlarms = o.Polling.MaxAlarms,
TimersEnabled = o.Polling.TimersEnabled,
ReconnectInitialMs = o.Reconnect.InitialDelayMs,
ReconnectMaxMs = o.Reconnect.MaxDelayMs,
_bag = bag,
};
}
public string ToJson()
{
// Remove any case-variant of the keys this form owns so the bag never carries two spellings.
foreach (var k in new[] { "TimeoutMs", "ProbeTimeoutSeconds", "Access", "Polling", "Reconnect" })
foreach (var existing in _bag.Select(p => p.Key).Where(p => string.Equals(p, k, StringComparison.OrdinalIgnoreCase)).ToList())
_bag.Remove(existing);
_bag["TimeoutMs"] = TimeoutMs;
_bag["ProbeTimeoutSeconds"] = ProbeTimeoutSeconds;
var access = new JsonObject
{
["TelemetryEnabled"] = TelemetryEnabled,
["DncLoginEnabled"] = DncLoginEnabled,
["DataEnabled"] = DataEnabled,
};
if (Blank(InspectPassword) is { } i) access["InspectPassword"] = i;
if (Blank(DncPassword) is { } d) access["DncPassword"] = d;
if (Blank(DataPassword) is { } a) access["DataPassword"] = a;
_bag["Access"] = access;
_bag["Polling"] = new JsonObject
{
["StatusMs"] = StatusMs,
["CustomMs"] = CustomMs,
["SlowMs"] = SlowMs,
["MaxAlarms"] = MaxAlarms,
["TimersEnabled"] = TimersEnabled,
};
_bag["Reconnect"] = new JsonObject { ["InitialDelayMs"] = ReconnectInitialMs, ["MaxDelayMs"] = ReconnectMaxMs };
return _bag.ToJsonString(JsonOptions);
}
/// <summary>The driver's own verdict on this config (device endpoints are validated on the device form).</summary>
public string? Validate()
{
var o = HeidenhainOptionsParser.Parse(ToJson());
var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions
{
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access,
Polling = o.Polling, Reconnect = o.Reconnect,
});
return errors.Count == 0 ? null : string.Join(" ", errors);
}
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
@@ -73,6 +73,7 @@
("Sql", DriverTypeNames.Sql),
("Calculation", DriverTypeNames.Calculation),
("MTConnect", DriverTypeNames.MTConnect),
("Heidenhain", DriverTypeNames.Heidenhain),
];
private string _type = DriverTypeNames.Modbus;
@@ -0,0 +1,78 @@
@* Typed tag-config editor for a Heidenhain LSV2 tag (lmxopcua#803). A thin shell over HeidenhainTagConfigModel —
every rule lives there (and, through it, in the driver's HeidenhainTagDefinitionFactory).
The driver is READ-ONLY: there is no Writable toggle, and the deploy gate refuses a ReadWrite access level.
Fixed leaves take the catalog's data type and array rank; only PLC:/DATA: bindings choose a type. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain
<div class="row g-2">
<div class="col-md-8">
<label class="form-label" for="hh-address">Address</label>
<input id="hh-address" type="text" class="form-control form-control-sm mono" list="hh-catalog"
value="@_m.Address" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? ""))"
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8" />
<datalist id="hh-catalog">
@foreach (var e in HeidenhainCatalog.Entries) { <option value="@e.Address"></option> }
</datalist>
<div class="form-text">
A fixed leaf, <span class="mono">PLC:AREA:INDEX</span> (element index into the control's advertised region; AREA one of
@string.Join(", ", HeidenhainAddress.AreaTokens)) or <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver).
</div>
</div>
<div class="col-md-4">
<label class="form-label" for="hh-datatype">Data type</label>
@if (_m.IsCatalogAddress)
{
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@CatalogShape()" disabled readonly />
<div class="form-text">Fixed by the namespace catalog.</div>
}
else
{
<select id="hh-datatype" class="form-select form-select-sm" value="@_m.DataType"
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.Int32)))">
@foreach (var v in HeidenhainAddress.CustomDataTypes) { <option value="@v">@v</option> }
</select>
<div class="form-text">Must hold the area's native range (no narrowing): Boolean for bit areas, String for STRING.</div>
}
</div>
</div>
@if (_m.Validate() is { } error)
{
<div class="text-danger small mt-2">@error</div>
}
@code {
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Two-way config callback.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — supplied by the hosting modal.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig JSON.</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private HeidenhainTagConfigModel _m = new();
private string? _lastConfigJson;
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = HeidenhainTagConfigModel.FromJson(ConfigJson);
}
private string CatalogShape() =>
HeidenhainCatalog.TryGet(_m.Address.Trim(), out var e) ? (e.IsArray ? $"{e.DataType}[]" : e.DataType.ToString()) : "";
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -280,6 +280,10 @@ public static class RawBrowseCommitMapper
}.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address);
// Heidenhain: a browsed leaf is a fixed catalog address; the typed model derives its data type and array
// flag from the catalog, so the committed tag opens in the editor and passes the driver's gate unchanged.
if (Is(driverType, DriverTypeNames.Heidenhain))
return new HeidenhainTagConfigModel { Address = address }.ToJson();
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
@@ -635,6 +635,15 @@ public static class CsvColumnMap
new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String),
}),
// Heidenhain (#803): address + the binding data type NAME + the array flag the catalog's array leaves
// require. The driver's HeidenhainTagDefinitionFactory is the gate a CSV row is validated against.
new RawKeyCsvDriverTagMap(DriverTypeNames.Heidenhain, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("HhAddress", "address", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("HhDataType", "dataType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("HhIsArray", "isArray", null), CsvRawKeyKind.Bool),
}),
};
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
@@ -0,0 +1,82 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Typed model of a Heidenhain raw tag's <c>TagConfig</c> (lmxopcua#803). Keys: <c>address</c>, <c>dataType</c>
/// (a NAME), <c>isArray</c>. Unknown keys (historian intent, …) are preserved.
/// </summary>
/// <remarks>
/// <para><b>The driver is the only authority.</b> <see cref="Validate"/> asks
/// <see cref="HeidenhainTagDefinitionFactory.TryFromTagConfig"/> about the blob this model would save (or, on
/// first load, the blob it was loaded from — CSV import and the API save verbatim), so the editor, the deploy
/// gate and the runtime accept exactly the same tags.</para>
/// <para><b>Shape is derived, not authored, for fixed leaves.</b> For a catalog address <see cref="ToJson"/>
/// writes the catalog's data type and its array flag, so the node's rank always matches what the driver
/// publishes. A PLC/DATA binding carries the operator-chosen type. A stray <c>writable</c> key is dropped —
/// the driver is read-only.</para>
/// </remarks>
public sealed class HeidenhainTagConfigModel
{
/// <summary>The address: a catalog leaf, <c>PLC:AREA:INDEX</c> or <c>DATA:/path</c>.</summary>
public string Address { get; set; } = "";
/// <summary>The binding data type (ignored for fixed leaves, whose type is the catalog's).</summary>
public DriverDataType DataType { get; set; } = DriverDataType.Int32;
private JsonObject _bag = new();
private string? _source;
/// <summary>True when <see cref="Address"/> is a fixed catalog leaf.</summary>
public bool IsCatalogAddress => HeidenhainCatalog.TryGet(Address.Trim(), out _);
public static HeidenhainTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
var address = TagConfigJson.GetString(o, "address") ?? "";
var typeText = TagConfigJson.GetString(o, "dataType");
DriverDataType type;
if (HeidenhainCatalog.TryGet(address.Trim(), out var entry)) type = entry.DataType;
else if (typeText is not null && HeidenhainTagDefinitionFactory.TryParseDataTypeName(typeText, out var parsed)) type = parsed;
else type = DriverDataType.Int32;
return new HeidenhainTagConfigModel { Address = address, DataType = type, _bag = o, _source = json ?? "" };
}
public string ToJson()
{
var address = Address.Trim();
TagConfigJson.Set(_bag, "address", address);
TagConfigJson.Set(_bag, "writable", null);
if (HeidenhainCatalog.TryGet(address, out var entry))
{
TagConfigJson.Set(_bag, "dataType", entry.DataType);
TagConfigJson.Set(_bag, "isArray", entry.IsArray ? true : null);
}
else
{
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "isArray", null);
}
return _source = TagConfigJson.Serialize(_bag);
}
public string? Validate()
{
if (string.IsNullOrWhiteSpace(Address)) return "An address is required (e.g. Status/ExecutionState, PLC:WORD:4, DATA:/PLC/memory/W/8).";
var blob = _source is null ? ToJsonPreview() : _source;
return HeidenhainTagDefinitionFactory.TryFromTagConfig(TagConfigParseGate.AuthoringRawPath, "", blob, out _, out var error)
? null
: error;
}
private string ToJsonPreview()
{
var saved = _source;
var json = ToJson();
_source = saved;
return json;
}
}
@@ -25,6 +25,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
[DriverTypeNames.Heidenhain] = typeof(Components.Shared.Uns.TagEditors.HeidenhainTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -27,6 +27,7 @@ public static class TagConfigValidator
[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Heidenhain] = j => HeidenhainTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -52,6 +52,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
@@ -41,6 +41,9 @@ public sealed class DriverPageJsonConverterTests
new Dictionary<Type, JsonSerializerOptions>
{
[typeof(MqttDriverForm)] = MqttJson.Options,
// HeidenhainDriverForm (#803) is a shell over HeidenhainDriverFormModel, which writes through its own
// JsonOptions (a JsonObject bag that preserves the Devices collection) — the same pattern as MQTT.
[typeof(HeidenhainDriverForm)] = HeidenhainDriverFormModel.JsonOptions,
};
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
@@ -0,0 +1,122 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// lmxopcua#803: the Heidenhain authoring surfaces (driver form, device form, tag editor, CSV, browse commit)
/// write exactly what the driver factory and the deploy gate accept — the "registered but unauthorable" and
/// "numerically-serialized enum" defect classes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainAuthoringTests
{
[Fact]
public void Every_authoring_seam_is_registered()
{
DriverConfigFormMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(HeidenhainDriverForm));
DeviceFormMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(HeidenhainDeviceForm));
TagConfigEditorMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(Components.Shared.Uns.TagEditors.HeidenhainTagConfigEditor));
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, "{}").ShouldNotBeNull();
CsvColumnMap.Resolve(DriverTypeNames.Heidenhain).ShouldNotBeNull();
}
[Fact]
public void A_default_driver_form_save_is_accepted_by_the_factory_and_preserves_devices()
{
var authored = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"m1"}],"Custom":42}""";
var m = HeidenhainDriverFormModel.FromJson(authored);
m.Validate().ShouldBeNull();
m.InspectPassword = "secret:lsv2/inspect";
m.TimersEnabled = true;
var json = m.ToJson();
var o = HeidenhainOptionsParser.Parse(json);
HeidenhainOptionsParser.Validate(o).ShouldBeEmpty(); // the factory's ParseOptions gate
o.Devices.Single().Host.ShouldBe("10.0.0.5");
o.Access.InspectPassword.ShouldBe("secret:lsv2/inspect");
o.Polling.TimersEnabled.ShouldBeTrue();
JsonNode.Parse(json)!["Custom"]!.GetValue<int>().ShouldBe(42);
// no field is ever written as a number where the parser expects a name — there are no enums at all
HeidenhainDriverFormModel.FromJson(json).ToJson().ShouldBe(json);
}
[Fact]
public void Driver_form_refuses_literal_passwords_and_out_of_range_values()
{
var m = HeidenhainDriverFormModel.FromJson("{}");
m.DncPassword = "hunter2";
m.Validate()!.ShouldNotContain("hunter2");
m.DncPassword = null;
m.StatusMs = 10;
m.Validate().ShouldNotBeNull();
}
[Fact]
public void Device_form_round_trips_and_validates_with_the_driver_rule()
{
var d = HeidenhainDeviceModel.FromJson("""{"Host":"cnc-7","Note":"keep"}""");
d.Port.ShouldBe(19000);
d.Validate().ShouldBeNull();
d.DataPassword = "plain";
d.Validate().ShouldNotBeNull();
d.DataPassword = "env:LSV2_DATA";
var json = d.ToJson();
JsonNode.Parse(json)!["Note"]!.GetValue<string>().ShouldBe("keep");
var opts = HeidenhainOptionsParser.Parse($$"""{"Devices":[{{json}}]}""");
opts.Devices.Single().DataPassword.ShouldBe("env:LSV2_DATA");
HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host required
}
[Theory]
[InlineData("Axes/Positions")]
[InlineData("Alarms/Messages")]
[InlineData("Status/ExecutionState")]
[InlineData("Capabilities/DncLogin")]
public void Catalog_addresses_derive_type_and_rank_so_the_driver_accepts_them(string address)
{
var json = new HeidenhainTagConfigModel { Address = address }.ToJson();
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out var error).ShouldBeTrue(error);
HeidenhainCatalog.TryGet(address, out var e).ShouldBeTrue();
def!.DataType.ShouldBe(e!.DataType);
def.IsArray.ShouldBe(e.IsArray);
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
}
[Fact]
public void Editor_writes_the_data_type_as_a_name_and_drops_a_stray_writable_flag()
{
var m = HeidenhainTagConfigModel.FromJson("""{"address":"PLC:WORD:2","dataType":"Int64","writable":true,"isHistorized":true}""");
m.Validate().ShouldNotBeNull(); // as loaded: the driver refuses writable:true
var json = m.ToJson();
var o = JsonNode.Parse(json)!.AsObject();
o["dataType"]!.GetValue<string>().ShouldBe("Int64");
o.ContainsKey("writable").ShouldBeFalse();
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
HeidenhainTagConfigModel.FromJson(json).Validate().ShouldBeNull();
}
[Fact]
public void Editor_refuses_a_narrowing_binding()
{
var m = new HeidenhainTagConfigModel { Address = "PLC:DWORD:0", DataType = DriverDataType.Int16 };
m.Validate().ShouldNotBeNull();
}
[Theory]
[InlineData("Status/ExecutionState")]
[InlineData("Axes/Names")]
public void Browse_committed_leaves_open_in_the_editor_and_pass_the_driver_gate(string address)
{
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Heidenhain, address);
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
HeidenhainTagConfigModel.FromJson(json).Address.ShouldBe(address);
}
}
@@ -74,6 +74,7 @@ public sealed class TagConfigNonEnumParityTests
[DriverTypeNames.MTConnect] = j => MTConnectTagDefinitionFactory.TryFromTagConfig(j, out _, out _),
[DriverTypeNames.Calculation] = j => CalculationTagDefinitionFactory.TryReadTagConfig(j, out _),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagDefinitionFactory.TryReadNodeId(j, out _),
[DriverTypeNames.Heidenhain] = j => ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.HeidenhainTagDefinitionFactory.TryFromTagConfig(P, "", j, out _, out _),
};
private static bool RuntimeAccepts(string driverType, string blob) => RuntimeParses[driverType](blob);
@@ -91,6 +92,7 @@ public sealed class TagConfigNonEnumParityTests
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.AbCip, DriverTypeNames.AbLegacy,
DriverTypeNames.TwinCAT, DriverTypeNames.FOCAS, DriverTypeNames.Mqtt, DriverTypeNames.Sql,
DriverTypeNames.MTConnect, DriverTypeNames.Calculation, DriverTypeNames.OpcUaClient,
DriverTypeNames.Heidenhain,
];
foreach (var driverType in mapped)
@@ -425,6 +427,20 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.MTConnect, """{"dataItemId":"Xact"}"""),
(DriverTypeNames.MTConnect, """{"fullName":"Xact","driverDataType":"Strng"}"""),
(DriverTypeNames.Heidenhain, "{}"),
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState"}"""),
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","dataType":"Int32"}"""),
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","dataType":"String"}"""),
(DriverTypeNames.Heidenhain, """{"address":"Axes/Names","isArray":true}"""),
(DriverTypeNames.Heidenhain, """{"address":"Axes/Names"}"""),
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4","dataType":"Int32"}"""),
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4","dataType":"8"}"""),
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4"}"""),
(DriverTypeNames.Heidenhain, """{"address":"PLC:DWORD:0","dataType":"Int16"}"""),
(DriverTypeNames.Heidenhain, """{"address":"DATA:/PLC/memory/W/8","dataType":"Int64"}"""),
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","writable":true}"""),
(DriverTypeNames.Heidenhain, Pad + """{"address":"Overrides/Feed"}"""),
(DriverTypeNames.Calculation, "{}"),
(DriverTypeNames.Calculation, """{"scriptId":"S1"}"""),
(DriverTypeNames.Calculation, """{"scriptId":"S1","changeTriggered":true,"timerIntervalMs":5000}"""),
@@ -602,6 +618,9 @@ public sealed class TagConfigNonEnumParityTests
new MTConnectTagConfigModel { FullName = "Xact", DataType = DriverDataType.Float64 }.ToJson());
data.Add(DriverTypeNames.Mqtt,
new MqttTagConfigModel { Mode = MqttMode.Plain, Topic = "otopcua/fixture/a" }.ToJson());
data.Add(DriverTypeNames.Heidenhain, new HeidenhainTagConfigModel { Address = "Axes/Positions" }.ToJson());
data.Add(DriverTypeNames.Heidenhain,
new HeidenhainTagConfigModel { Address = "PLC:INPUT_WORD:3", DataType = DriverDataType.Int32 }.ToJson());
data.Add(DriverTypeNames.Mqtt, new MqttTagConfigModel
{
Mode = MqttMode.SparkplugB,
@@ -18,6 +18,7 @@ internal static class TagConfigRoundTrip
DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Heidenhain => HeidenhainTagConfigModel.FromJson(json).ToJson(),
_ => throw new ArgumentOutOfRangeException(nameof(driverType)),
};
}