From 3641d561541d17f9a479301fdf68a679f55eb42b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:17:26 -0500 Subject: [PATCH] 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. --- .../DeviceForms/HeidenhainDeviceForm.razor | 76 +++++++++ .../DeviceForms/HeidenhainDeviceModel.cs | 55 +++++++ .../Shared/Drivers/DriverFormMaps.cs | 2 + .../Drivers/Forms/HeidenhainDriverForm.razor | 152 ++++++++++++++++++ .../Forms/HeidenhainDriverFormModel.cs | 114 +++++++++++++ .../Shared/Raw/RawDriverTypeDialog.razor | 1 + .../HeidenhainTagConfigEditor.razor | 78 +++++++++ .../Uns/RawBrowseCommitMapper.cs | 4 + .../Uns/TagEditors/CsvColumnMap.cs | 9 ++ .../TagEditors/HeidenhainTagConfigModel.cs | 82 ++++++++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 1 + .../Uns/TagEditors/TagConfigValidator.cs | 1 + .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 1 + .../DriverPageJsonConverterTests.cs | 3 + .../Uns/HeidenhainAuthoringTests.cs | 122 ++++++++++++++ .../Uns/TagConfigNonEnumParityTests.cs | 19 +++ .../Uns/TagConfigRoundTrip.cs | 1 + 17 files changed, 721 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor new file mode 100644 index 000000000..ecaeade4b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor @@ -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 + +
+
LSV2 endpoint
+
+
+
+ + +
Control hostname or IP. No default endpoint. Plain LSV2 is unencrypted — route it over an externally managed secure network.
+
+
+ + +
Default 19000.
+
+
+
+
+ +
+
Per-device credentials (optional overrides)
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
Blank = use the driver-level reference. secret:<name> resolves from the node's secret store, env:<VAR> from the environment, at each login. A literal password is never stored.
+ @if (_model.Validate() is { } error) + { +
@error
+ } +
+
+ +@code { + /// The device's DeviceConfig JSON. + [Parameter] public string DeviceConfigJson { get; set; } = "{}"; + /// Fired whenever the endpoint changes — enables @bind-DeviceConfigJson. + [Parameter] public EventCallback 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; + } + } + + /// Serializes the current endpoint to DeviceConfig JSON. + public string GetConfigJson() => _model.ToJson(); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DeviceConfigJsonChanged.InvokeAsync(json); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs new file mode 100644 index 000000000..fe6ae03d6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs @@ -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; + +/// +/// The Heidenhain device (one control's endpoint) DeviceConfig, PascalCase keys matching +/// . Unknown keys are preserved. Password fields hold REFERENCES only +/// (blank / secret: / env:); uses the driver's own rule. +/// +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: or env: — never a literal."); + return errors.Count == 0 ? null : string.Join(" ", errors); + } + + private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs index 3357f68d5..13ff83b78 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs @@ -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), }; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor new file mode 100644 index 000000000..6c90e3753 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor @@ -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 + +
+
Connection
+
+
+
+ + +
100–60000. Bounds connect and each complete response. Default 5000.
+
+
+ + +
+
+ + +
Backoff doubles from the initial delay up to this ceiling.
+
+
+ + +
+
+
+
+ +
+
Access (read-only roles)
+
+
+
+
+ + +
+
Execution/program state, program, overrides, axes, tool, alarms. Off = no runtime-info requests.
+
+
+
+ + +
+
A refused DNC login never blocks reads the control permits under INSPECT.
+
+
+
+ + +
+
Opt-in DATA login, only when a DATA: binding needs it (reference-derived; iTNC only).
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
References only: blank, secret:<name> (node secret store) or env:<VAR>. Resolved at each login; a literal password is refused.
+
+
+ +
+
Polling & limits
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
Selectors 30–34. Units/semantics unverified on real controls — confirm before using as durations.
+
+
+
Intervals 100–3600000 ms. OPC reads and subscriptions are served from the cache; sampling cannot exceed these cadences.
+ @if (_m.Validate() is { } error) + { +
@error
+ } +
+
+ + + +@code { + /// The driver-level DriverConfig JSON. + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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; + } + } + + /// Serializes the current config. + 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); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs new file mode 100644 index 000000000..a5717b33b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs @@ -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; + +/// +/// 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 Devices collection, future keys) survives a load→save. Every +/// field is an int/bool/string — there is no enum to serialize numerically — and asks the +/// driver's own , so the form accepts exactly what the factory accepts. +/// +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; + + /// + /// 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 DriverPageJsonConverterTests. + /// + 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); + } + + /// The driver's own verdict on this config (device endpoints are validated on the device form). + 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(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index c71705441..282923123 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -73,6 +73,7 @@ ("Sql", DriverTypeNames.Sql), ("Calculation", DriverTypeNames.Calculation), ("MTConnect", DriverTypeNames.MTConnect), + ("Heidenhain", DriverTypeNames.Heidenhain), ]; private string _type = DriverTypeNames.Modbus; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor new file mode 100644 index 000000000..a5a44cb6c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor @@ -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 + +
+
+ + + + @foreach (var e in HeidenhainCatalog.Entries) { } + +
+ A fixed leaf, PLC:AREA:INDEX (element index into the control's advertised region; AREA one of + @string.Join(", ", HeidenhainAddress.AreaTokens)) or DATA:/path (needs DATA access enabled on the driver). +
+
+
+ + @if (_m.IsCatalogAddress) + { + +
Fixed by the namespace catalog.
+ } + else + { + +
Must hold the area's native range (no narrowing): Boolean for bit areas, String for STRING.
+ } +
+
+ +@if (_m.Validate() is { } error) +{ +
@error
+} + +@code { + /// The tag's raw TagConfig JSON (supports @bind-ConfigJson). + [Parameter] public string? ConfigJson { get; set; } + /// Two-way config callback. + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — supplied by the hosting modal. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON. + [Parameter] public Func 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(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs index befe79e4a..d396d5554 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs index e58c8f7fb..6d7c8bd3d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs new file mode 100644 index 000000000..ace423ce3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs @@ -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; + +/// +/// Typed model of a Heidenhain raw tag's TagConfig (lmxopcua#803). Keys: address, dataType +/// (a NAME), isArray. Unknown keys (historian intent, …) are preserved. +/// +/// +/// The driver is the only authority. asks +/// 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. +/// Shape is derived, not authored, for fixed leaves. For a catalog address +/// 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 writable key is dropped — +/// the driver is read-only. +/// +public sealed class HeidenhainTagConfigModel +{ + /// The address: a catalog leaf, PLC:AREA:INDEX or DATA:/path. + public string Address { get; set; } = ""; + + /// The binding data type (ignored for fixed leaves, whose type is the catalog's). + public DriverDataType DataType { get; set; } = DriverDataType.Int32; + + private JsonObject _bag = new(); + private string? _source; + + /// True when is a fixed catalog leaf. + 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; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index bd58ccf39..b7ba8f5d4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -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), }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index fb713cf64..adc197984 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -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(), }; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 9d241e927..91182ad8b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -52,6 +52,7 @@ +