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
+
+
+ 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 @@
+