diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index fd177ab16..ee8036a72 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -42,6 +42,8 @@
+
+
@@ -120,6 +122,7 @@
+
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
index 5c03e45e8..c50364821 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
@@ -62,6 +62,9 @@ public static class DriverTypeNames
/// opt-in observation write-back via the Agent's AllowPut POST surface.
public const string MTConnect = "MTConnect";
+ /// Heidenhain LSV2 CNC driver — native, read-only (logins + read requests only) over TCP 19000.
+ public const string Heidenhain = "Heidenhain";
+
///
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored DriverType).
@@ -80,5 +83,6 @@ public static class DriverTypeNames
Sql,
Mqtt,
MTConnect,
+ Heidenhain,
];
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs
new file mode 100644
index 000000000..6dcbe5def
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs
@@ -0,0 +1,225 @@
+using System.Text;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// The PLC memory areas a PLC: binding may name (advertised by the control's R_PR layout).
+public enum HeidenhainPlcArea
+{
+ Marker,
+ Input,
+ Output,
+ Counter,
+ Timer,
+ Byte,
+ Word,
+ Dword,
+ String,
+ InputWord,
+ OutputWord,
+}
+
+/// What an address resolves to.
+public enum HeidenhainAddressKind
+{
+ /// A fixed catalog leaf ().
+ Standard,
+
+ /// A PLC memory element read via R_MB.
+ Plc,
+
+ /// A data-path value read via R_DP (DATA role).
+ DataPath,
+}
+
+/// A parsed, validated Heidenhain tag address.
+/// The address family.
+/// The canonical address text.
+/// The fixed leaf, for .
+/// The PLC area, for .
+/// The PLC element index (not an absolute address), for .
+/// The data path as authored, for .
+public sealed record HeidenhainAddress(
+ HeidenhainAddressKind Kind,
+ string Text,
+ HeidenhainCatalogEntry? Catalog = null,
+ HeidenhainPlcArea? Area = null,
+ long Index = 0,
+ string? DataPath = null)
+{
+ /// Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header).
+ public const int MaxPayload = 247;
+
+ /// Address prefix of a PLC binding.
+ public const string PlcPrefix = "PLC:";
+
+ /// Address prefix of a DATA-path binding.
+ public const string DataPrefix = "DATA:";
+
+ /// The data types a custom (PLC/DATA) binding may declare — the reference's BOOLEAN/INT16/INT32/INT64/DOUBLE/STRING.
+ public static readonly IReadOnlySet CustomDataTypes = new HashSet
+ {
+ DriverDataType.Boolean, DriverDataType.Int16, DriverDataType.Int32,
+ DriverDataType.Int64, DriverDataType.Float64, DriverDataType.String,
+ };
+
+ private static readonly IReadOnlyDictionary AreaNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["MARKER"] = HeidenhainPlcArea.Marker,
+ ["INPUT"] = HeidenhainPlcArea.Input,
+ ["OUTPUT"] = HeidenhainPlcArea.Output,
+ ["COUNTER"] = HeidenhainPlcArea.Counter,
+ ["TIMER"] = HeidenhainPlcArea.Timer,
+ ["BYTE"] = HeidenhainPlcArea.Byte,
+ ["WORD"] = HeidenhainPlcArea.Word,
+ ["DWORD"] = HeidenhainPlcArea.Dword,
+ ["STRING"] = HeidenhainPlcArea.String,
+ ["INPUT_WORD"] = HeidenhainPlcArea.InputWord,
+ ["OUTPUT_WORD"] = HeidenhainPlcArea.OutputWord,
+ };
+
+ /// The wire spelling of a PLC area (MARKER, INPUT_WORD, …).
+ /// The area.
+ /// Its address token.
+ public static string AreaToken(HeidenhainPlcArea area) => AreaNames.First(kv => kv.Value == area).Key;
+
+ /// The PLC area tokens, in declaration order.
+ public static IReadOnlyList AreaTokens { get; } = AreaNames.Keys.ToList();
+
+ /// True for the one-byte-per-entry Boolean bit areas.
+ /// The area.
+ /// Whether it is a bit area.
+ public static bool IsBitArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Marker or HeidenhainPlcArea.Input
+ or HeidenhainPlcArea.Output or HeidenhainPlcArea.Counter or HeidenhainPlcArea.Timer;
+
+ /// True for the integer areas (BYTE/WORD/DWORD signed; INPUT_WORD/OUTPUT_WORD unsigned 16-bit).
+ /// The area.
+ /// Whether it is an integer area.
+ public static bool IsIntegerArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Byte or HeidenhainPlcArea.Word
+ or HeidenhainPlcArea.Dword or HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord;
+
+ ///
+ /// Parses an address. Never throws; explains a rejection.
+ ///
+ /// The authored address.
+ /// The parsed address on success.
+ /// Why the address was rejected.
+ /// True when the address is valid.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainAddress? address, out string? error)
+ {
+ address = null;
+ error = null;
+ var t = text?.Trim() ?? "";
+ if (t.Length == 0)
+ {
+ error = "An address is required.";
+ return false;
+ }
+
+ if (t.StartsWith(DataPrefix, StringComparison.Ordinal))
+ {
+ var path = t[DataPrefix.Length..];
+ if (!TryValidateDataPath(path, out error)) return false;
+ address = new HeidenhainAddress(HeidenhainAddressKind.DataPath, t, DataPath: path);
+ return true;
+ }
+
+ if (t.StartsWith(PlcPrefix, StringComparison.Ordinal))
+ {
+ var parts = t.Split(':');
+ if (parts.Length != 3 || !AreaNames.TryGetValue(parts[1], out var area))
+ {
+ error = $"Expected PLC:AREA:INDEX with AREA one of {string.Join(", ", AreaTokens)}.";
+ return false;
+ }
+
+ if (parts[2].Length is < 1 or > 10 || !parts[2].All(char.IsAsciiDigit))
+ {
+ error = "PLC index must be 1–10 decimal digits (an element index, not an absolute address).";
+ return false;
+ }
+
+ var index = long.Parse(parts[2], System.Globalization.CultureInfo.InvariantCulture);
+ if (index > 0xFFFF_FFFFL)
+ {
+ error = "PLC index exceeds uint32.";
+ return false;
+ }
+
+ address = new HeidenhainAddress(HeidenhainAddressKind.Plc, $"{PlcPrefix}{parts[1]}:{index}", Area: area, Index: index);
+ return true;
+ }
+
+ if (HeidenhainCatalog.TryGet(t, out var entry))
+ {
+ address = new HeidenhainAddress(HeidenhainAddressKind.Standard, t, Catalog: entry);
+ return true;
+ }
+
+ error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding or a DATA:/path binding.";
+ return false;
+ }
+
+ ///
+ /// The reference's data-path rules: non-blank, NUL-free Latin-1, and short enough that the
+ /// R_DP payload (4 zero bytes + normalised path + NUL) fits the 247-byte legacy payload.
+ ///
+ /// The data path.
+ /// Why the path was rejected.
+ /// True when the path is sendable.
+ public static bool TryValidateDataPath(string? path, out string? error)
+ {
+ error = null;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ error = "DATA path is blank.";
+ return false;
+ }
+
+ foreach (var c in path)
+ {
+ if (c == '\0' || c > 0xFF)
+ {
+ error = "DATA path must be NUL-free Latin-1 text.";
+ return false;
+ }
+ }
+
+ if (4 + path.Length + 1 > MaxPayload)
+ {
+ error = $"DATA path is too long (max {MaxPayload - 5} characters).";
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// The reference's datatype-vs-area rules for a PLC: binding: Boolean only for bit areas, String
+ /// only for STRING, and integers wide enough for the native range (never narrowing or wrapping).
+ ///
+ /// The PLC area.
+ /// The declared data type.
+ /// Null when acceptable, else the reason.
+ public static string? CheckPlcDataType(HeidenhainPlcArea area, DriverDataType type)
+ {
+ if (IsBitArea(area))
+ return type == DriverDataType.Boolean ? null : $"PLC {AreaToken(area)} is a Boolean area — declare Boolean.";
+ if (area == HeidenhainPlcArea.String)
+ return type == DriverDataType.String ? null : "PLC STRING requires data type String.";
+ if (type is not (DriverDataType.Int16 or DriverDataType.Int32 or DriverDataType.Int64))
+ return $"PLC {AreaToken(area)} is an integer area — declare Int16, Int32 or Int64.";
+ if (type == DriverDataType.Int16 && area is not (HeidenhainPlcArea.Byte or HeidenhainPlcArea.Word))
+ return $"Int16 narrows the native range of PLC {AreaToken(area)} — declare Int32 or Int64.";
+ return null;
+ }
+
+ /// The normalised data path the R_DP request carries (/→\, "→').
+ /// The authored path.
+ /// The normalised path.
+ public static string NormalizeDataPath(string path) => path.Replace('/', '\\').Replace('"', '\'');
+
+ /// Latin-1 encoding used by every LSV2 string.
+ public static Encoding Latin1 => Encoding.Latin1;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs
new file mode 100644
index 000000000..0c5717b40
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs
@@ -0,0 +1,125 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// The three acquisition cadences a Heidenhain tag can ride.
+public enum HeidenhainPollGroup
+{
+ /// Execution/program state, program, overrides, axes (Polling.StatusMs).
+ Status,
+
+ /// Configured PLC / DATA bindings (Polling.CustomMs).
+ Custom,
+
+ /// Identity, tool, alarms, timers and capability strings (Polling.SlowMs).
+ Slow,
+}
+
+/// One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it.
+/// The device-relative address, e.g. Status/ExecutionState.
+/// The OPC UA data type the driver publishes.
+/// True for the variable-length array leaves (axes, alarms).
+/// The poll cadence.
+///
+/// The feature the leaf is acquired through (one wire read per feature per batch); for a
+/// Capabilities/ leaf the feature whose state it reports.
+///
+public sealed record HeidenhainCatalogEntry(
+ string Address, DriverDataType DataType, bool IsArray, HeidenhainPollGroup Group, string Feature)
+{
+ /// True for a local Capabilities/ string, which never causes a wire read.
+ public bool IsCapability => Address.StartsWith(HeidenhainCatalog.CapabilitiesFolder + "/", StringComparison.Ordinal);
+}
+
+///
+/// The stable, general-purpose device-relative namespace (ported from the ignitionoee v0.20.3 reference
+/// HeidenhainTags). Fixed leaves avoid control-dependent path churn; axes use aligned name and
+/// position arrays. There is no OEE mapping here — raw controller enums and units are preserved.
+///
+public static class HeidenhainCatalog
+{
+ /// The capability-string folder.
+ public const string CapabilitiesFolder = "Capabilities";
+
+ /// Feature id for the version / identity reply.
+ public const string IdentityFeature = "Identity";
+
+ /// Feature id reported for the R_PR memory layout used by PLC bindings.
+ public const string PlcLayoutFeature = "PlcLayout";
+
+ /// Feature id reported for the optional DNC login.
+ public const string DncLoginFeature = "DncLogin";
+
+ /// Feature id reported for the opt-in DATA login.
+ public const string DataLoginFeature = "DataLogin";
+
+ /// Experimental timer leaf names, in selector order (30..34).
+ public static readonly IReadOnlyList TimerNames =
+ ["NcUptime", "MachineUptime", "MachineRuntime", "Spindle0Runtime", "Spindle1Runtime"];
+
+ /// Feature-state strings published on Capabilities/ leaves.
+ public static class States
+ {
+ public const string Unknown = "Unknown";
+ public const string Supported = "Supported";
+ public const string Disabled = "Disabled";
+ public const string AccessDenied = "AccessDenied";
+ public const string Unsupported = "Unsupported";
+ public const string InvalidData = "InvalidData";
+ public const string CommunicationLost = "CommunicationLost";
+ }
+
+ /// Every fixed leaf, value leaves first then the capability strings.
+ public static IReadOnlyList Entries { get; } = Build();
+
+ private static readonly IReadOnlyDictionary ByAddress =
+ Entries.ToDictionary(e => e.Address, StringComparer.Ordinal);
+
+ /// Looks up a fixed leaf by its exact (case-sensitive) address.
+ /// The address.
+ /// The entry, when found.
+ /// True when is a fixed leaf.
+ public static bool TryGet(string address, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainCatalogEntry? entry)
+ => ByAddress.TryGetValue(address, out entry);
+
+ /// The value features (excluding capability-only features), in catalog order.
+ public static IReadOnlyList ValueFeatures { get; } =
+ Entries.Where(e => !e.IsCapability).Select(e => e.Feature).Distinct().ToList();
+
+ private static List Build()
+ {
+ var list = new List();
+ void Add(string folder, DriverDataType type, bool array, HeidenhainPollGroup group, string feature, params string[] names)
+ {
+ foreach (var n in names) list.Add(new($"{folder}/{n}", type, array, group, feature));
+ }
+
+ Add("Controller", DriverDataType.String, false, HeidenhainPollGroup.Slow, IdentityFeature, "Model", "NcVersion", "PlcVersion", "Options");
+ Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "Execution", "ExecutionState");
+ Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "ProgramState", "ProgramState");
+ Add("Program", DriverDataType.String, false, HeidenhainPollGroup.Status, "Program", "Main", "Current");
+ Add("Program", DriverDataType.Int64, false, HeidenhainPollGroup.Status, "Program", "Line");
+ Add("Overrides", DriverDataType.Float64, false, HeidenhainPollGroup.Status, "Overrides", "Feed", "Spindle", "Rapid");
+ Add("Axes", DriverDataType.String, true, HeidenhainPollGroup.Status, "Axes", "Names");
+ Add("Axes", DriverDataType.Float64, true, HeidenhainPollGroup.Status, "Axes", "Positions");
+ Add("Tool", DriverDataType.Int64, false, HeidenhainPollGroup.Slow, "Tool", "Number");
+ Add("Tool", DriverDataType.Int32, false, HeidenhainPollGroup.Slow, "Tool", "Index", "AxisCode");
+ Add("Tool", DriverDataType.Float64, false, HeidenhainPollGroup.Slow, "Tool", "Length", "Radius");
+ Add("Alarms", DriverDataType.Int32, true, HeidenhainPollGroup.Slow, "Alarms", "Classes", "Groups", "Numbers");
+ Add("Alarms", DriverDataType.String, true, HeidenhainPollGroup.Slow, "Alarms", "Messages");
+ Add("Alarms", DriverDataType.Int32, false, HeidenhainPollGroup.Slow, "Alarms", "Count");
+ foreach (var t in TimerNames)
+ Add("Timers", DriverDataType.Int64, false, HeidenhainPollGroup.Slow, TimerFeature(t), t);
+
+ var features = list.Select(e => e.Feature).Distinct().ToList();
+ features.AddRange([PlcLayoutFeature, DncLoginFeature, DataLoginFeature]);
+ foreach (var f in features)
+ list.Add(new($"{CapabilitiesFolder}/{f}", DriverDataType.String, false, HeidenhainPollGroup.Slow, f));
+ return list;
+ }
+
+ /// The feature id of an experimental timer leaf.
+ /// One of .
+ /// Timer_<name>.
+ public static string TimerFeature(string timerName) => "Timer_" + timerName;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs
new file mode 100644
index 000000000..157b171dd
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs
@@ -0,0 +1,128 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Heidenhain LSV2 driver configuration (lmxopcua#803). One driver instance serves N controls; each
+/// /raw Device is one controller endpoint (), merged in by
+/// DriverDeviceConfigMerger as the Devices array.
+///
+///
+/// No enum-typed fields, by design. Every value here is an int, a bool or a string, so the
+/// repo-wide "AdminUI form serializes an enum as a number, the factory DTO expects a name" trap cannot
+/// arise for this driver.
+/// Read-only by construction. Nothing in this document can enable a write: the driver does not
+/// implement IWritable, and its outbound LSV2 allowlist admits only logins and read requests.
+///
+public sealed class HeidenhainDriverOptions
+{
+ /// Default LSV2 TCP port.
+ public const int DefaultPort = 19000;
+
+ /// The configured controllers (one per /raw Device).
+ public IReadOnlyList Devices { get; init; } = [];
+
+ /// The authored raw tags this driver serves, injected by the deploy artifact.
+ public IReadOnlyList RawTags { get; init; } = [];
+
+ /// Connect + whole-response transaction deadline, ms (100–60000). Default 5000.
+ public int TimeoutMs { get; init; } = 5000;
+
+ /// Credentials and optional-role access.
+ public HeidenhainAccessOptions Access { get; init; } = new();
+
+ /// Poll cadences and acquisition limits.
+ public HeidenhainPollingOptions Polling { get; init; } = new();
+
+ /// Bounded reconnect backoff after a lost or failed session.
+ public HeidenhainReconnectOptions Reconnect { get; init; } = new();
+
+ /// Timeout for the AdminUI Test Connect probe, seconds (1–60). Default 10.
+ public int ProbeTimeoutSeconds { get; init; } = 10;
+}
+
+/// One Heidenhain control endpoint — a /raw Device's DeviceConfig.
+public sealed class HeidenhainDeviceOptions
+{
+ /// Hostname or IP address of the control. Required; there is no default endpoint.
+ public string Host { get; init; } = "";
+
+ /// LSV2 TCP port (1–65535). Default 19000.
+ public int Port { get; init; } = HeidenhainDriverOptions.DefaultPort;
+
+ /// The /raw Device name (the RawPath device segment) — the tag routing key.
+ public string? DeviceName { get; init; }
+
+ /// Optional per-device INSPECT password reference; overrides .
+ public string? InspectPassword { get; init; }
+
+ /// Optional per-device DNC password reference; overrides .
+ public string? DncPassword { get; init; }
+
+ /// Optional per-device DATA password reference; overrides .
+ public string? DataPassword { get; init; }
+}
+
+///
+/// Access settings. Password fields are references, never literals: blank (no password),
+/// secret:<name> (the node's encrypted secret store) or env:<VARIABLE>. A literal is
+/// refused at parse and at the deploy gate, so a controller password never lands in the config database.
+///
+public sealed class HeidenhainAccessOptions
+{
+ ///
+ /// Enables read-only telemetry (selectors 22–28/30–34/51). Off = no runtime-info requests at all; only
+ /// identity (and configured PLC/DATA bindings) are read. Default on.
+ ///
+ public bool TelemetryEnabled { get; init; } = true;
+
+ ///
+ /// Attempt the optional DNC login when telemetry is enabled. A refused DNC login never blocks reads the
+ /// control permits under INSPECT — each read reply is the authority. Default on.
+ ///
+ public bool DncLoginEnabled { get; init; } = true;
+
+ /// Opt-in DATA login, attempted only when a DATA: binding needs it. Default off.
+ public bool DataEnabled { get; init; }
+
+ /// INSPECT password reference (blank / secret: / env:).
+ public string? InspectPassword { get; init; }
+
+ /// DNC password reference (blank / secret: / env:).
+ public string? DncPassword { get; init; }
+
+ /// DATA password reference (blank / secret: / env:).
+ public string? DataPassword { get; init; }
+}
+
+/// Poll cadences (each 100–3600000 ms) and limits.
+public sealed class HeidenhainPollingOptions
+{
+ /// Status group: execution/program state, program, overrides, axes. Default 1000 ms.
+ public int StatusMs { get; init; } = 1000;
+
+ /// Custom group: PLC and DATA bindings. Default 1000 ms.
+ public int CustomMs { get; init; } = 1000;
+
+ /// Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms.
+ public int SlowMs { get; init; } = 10000;
+
+ /// Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64.
+ public int MaxAlarms { get; init; } = 64;
+
+ ///
+ /// Experimental timer selectors 30–34 (reference-derived; units/semantics unverified on a real control).
+ /// Default off.
+ ///
+ public bool TimersEnabled { get; init; }
+}
+
+/// Reconnect backoff: doubling from to .
+public sealed class HeidenhainReconnectOptions
+{
+ /// First retry delay, ms (100–600000). Default 1000.
+ public int InitialDelayMs { get; init; } = 1000;
+
+ /// Retry delay ceiling, ms (InitialDelayMs–600000). Default 30000.
+ public int MaxDelayMs { get; init; } = 30000;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs
new file mode 100644
index 000000000..22e80fa57
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs
@@ -0,0 +1,111 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// The one parser + validator of the Heidenhain DriverConfig document, shared by the driver factory,
+/// the Test-Connect probe, the deploy gate and the AdminUI form, so none of them can disagree about what a
+/// config means.
+///
+public static class HeidenhainOptionsParser
+{
+ /// Prefix of a secret-store password reference.
+ public const string SecretPrefix = "secret:";
+
+ /// Prefix of an environment-variable password reference.
+ public const string EnvPrefix = "env:";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ /// Deserialises a config document. Throws on unreadable JSON.
+ /// The DriverConfig JSON (blank ⇒ defaults).
+ /// The options.
+ public static HeidenhainDriverOptions Parse(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return new HeidenhainDriverOptions();
+ try
+ {
+ return JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidOperationException("Heidenhain driver config deserialised to null.");
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException($"Heidenhain driver config is not valid JSON: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Validates driver-level ranges and every password reference. Device endpoints are validated per device
+ /// by (a driver may be authored before its devices).
+ ///
+ /// The options.
+ /// Human-readable errors; empty when valid. Never echoes a credential value.
+ public static IReadOnlyList Validate(HeidenhainDriverOptions o)
+ {
+ ArgumentNullException.ThrowIfNull(o);
+ var errors = new List();
+ if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms.");
+ var p = o.Polling ?? new HeidenhainPollingOptions();
+ foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) })
+ if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms.");
+ if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256.");
+ var r = o.Reconnect ?? new HeidenhainReconnectOptions();
+ if (r.InitialDelayMs is < 100 or > 600_000) errors.Add("Reconnect.InitialDelayMs must be 100–600000 ms.");
+ if (r.MaxDelayMs < r.InitialDelayMs || r.MaxDelayMs > 600_000)
+ errors.Add("Reconnect.MaxDelayMs must be between InitialDelayMs and 600000 ms.");
+ if (o.ProbeTimeoutSeconds is < 1 or > 60) errors.Add("ProbeTimeoutSeconds must be 1–60.");
+ var a = o.Access ?? new HeidenhainAccessOptions();
+ CheckRef(errors, "Access.InspectPassword", a.InspectPassword);
+ CheckRef(errors, "Access.DncPassword", a.DncPassword);
+ CheckRef(errors, "Access.DataPassword", a.DataPassword);
+ foreach (var d in o.Devices ?? [])
+ {
+ var label = string.IsNullOrWhiteSpace(d.DeviceName) ? d.Host : d.DeviceName;
+ CheckRef(errors, $"Device '{label}' InspectPassword", d.InspectPassword);
+ CheckRef(errors, $"Device '{label}' DncPassword", d.DncPassword);
+ CheckRef(errors, $"Device '{label}' DataPassword", d.DataPassword);
+ }
+
+ return errors;
+ }
+
+ /// Validates one device endpoint.
+ /// The device.
+ /// Errors; empty when valid.
+ public static IReadOnlyList ValidateDevice(HeidenhainDeviceOptions d)
+ {
+ var errors = new List();
+ var h = d.Host ?? "";
+ if (h.Length == 0 || h.Length > 253 || h.Any(c => char.IsWhiteSpace(c) || c < 32 || c == '/' || c == '\\'))
+ errors.Add("Host must be a hostname or IP address without whitespace or a URL scheme.");
+ if (d.Port is < 1 or > 65535) errors.Add("Port must be 1–65535.");
+ return errors;
+ }
+
+ ///
+ /// True when is an acceptable password field: blank, secret:<name> or
+ /// env:<VARIABLE>. A literal password is refused so it never lands in the config database.
+ ///
+ /// The field value.
+ /// Whether it is a reference (or blank).
+ public static bool IsCredentialReference(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return true;
+ if (value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
+ return value.Length > SecretPrefix.Length && !string.IsNullOrWhiteSpace(value[SecretPrefix.Length..]);
+ if (value.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase))
+ return value.Length > EnvPrefix.Length && !string.IsNullOrWhiteSpace(value[EnvPrefix.Length..]);
+ return false;
+ }
+
+ private static void CheckRef(List errors, string field, string? value)
+ {
+ if (!IsCredentialReference(value))
+ errors.Add($"{field} must be blank, 'secret:' or 'env:' — a literal password is not stored.");
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs
new file mode 100644
index 000000000..a3c774701
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs
@@ -0,0 +1,230 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// One authored Heidenhain raw tag, resolved.
+/// The RawPath identity (driver wire reference).
+/// The /raw Device the tag lives under.
+/// The parsed address.
+/// The OPC data type the driver publishes.
+/// Whether the value is a 1-D array.
+public sealed record HeidenhainTagDefinition(
+ string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray)
+{
+ /// The poll cadence this tag rides.
+ public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard
+ ? Address.Catalog!.Group
+ : HeidenhainPollGroup.Custom;
+}
+
+///
+/// The single authority on a Heidenhain raw tag's TagConfig: consumed by the driver's table build,
+/// the deploy-time gate () and the AdminUI typed editor's Validate(), so the
+/// editor, the deploy gate and the runtime accept exactly the same blobs.
+///
+///
+/// Shape: {"address": "Status/ExecutionState"},
+/// {"address": "PLC:MARKER:32", "dataType": "Boolean"},
+/// {"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}. Array leaves
+/// (Axes/*, Alarms/Classes|Groups|Numbers|Messages) must carry "isArray": true so the
+/// node's rank matches what the driver publishes; every other address must not.
+/// Never a write flag. There is no writable key: the driver is read-only and the deploy
+/// gate refuses a ReadWrite access level on any Heidenhain tag.
+/// Data types are parsed by NAME only. A digits-only value (a numerically-serialized enum) is
+/// rejected rather than mapped to the ordinal's member.
+///
+public static class HeidenhainTagDefinitionFactory
+{
+ /// Parses one tag. Never throws.
+ /// The tag's RawPath.
+ /// The tag's Device name.
+ /// The TagConfig JSON.
+ /// The resolved definition on success.
+ /// Why the blob was rejected.
+ /// True when the driver will serve this tag.
+ public static bool TryFromTagConfig(
+ string rawPath, string deviceName, string? tagConfig,
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainTagDefinition? definition, out string? error)
+ {
+ definition = null;
+ error = null;
+ if (string.IsNullOrWhiteSpace(tagConfig))
+ {
+ error = "Heidenhain TagConfig is empty.";
+ return false;
+ }
+
+ string? addressText;
+ string? dataTypeText;
+ bool dataTypePresent;
+ bool? isArray;
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ error = "Heidenhain TagConfig must be a JSON object.";
+ return false;
+ }
+
+ addressText = ReadString(root, "address", out var addressBad);
+ if (addressBad)
+ {
+ error = "'address' must be a string.";
+ return false;
+ }
+
+ dataTypeText = ReadString(root, "dataType", out var typeBad);
+ if (typeBad)
+ {
+ error = "'dataType' must be a data type NAME (e.g. \"Int32\"), not a number.";
+ return false;
+ }
+
+ dataTypePresent = !string.IsNullOrWhiteSpace(dataTypeText);
+ isArray = null;
+ if (TryGetProperty(root, "isArray", out var arr))
+ {
+ if (arr.ValueKind is JsonValueKind.True or JsonValueKind.False) isArray = arr.GetBoolean();
+ else if (arr.ValueKind != JsonValueKind.Null)
+ {
+ error = "'isArray' must be true or false.";
+ return false;
+ }
+ }
+
+ if (TryGetProperty(root, "writable", out var w) && w.ValueKind == JsonValueKind.True)
+ {
+ error = "Heidenhain tags are read-only; remove \"writable\": true.";
+ return false;
+ }
+ }
+ catch (JsonException)
+ {
+ error = "Heidenhain TagConfig is not valid JSON.";
+ return false;
+ }
+
+ if (!HeidenhainAddress.TryParse(addressText, out var address, out error)) return false;
+
+ DriverDataType? declared = null;
+ if (dataTypePresent)
+ {
+ if (!TryParseDataTypeName(dataTypeText!, out var parsed))
+ {
+ error = $"dataType '{dataTypeText}' is not a valid DriverDataType name; valid: {string.Join(", ", Enum.GetNames())}.";
+ return false;
+ }
+
+ declared = parsed;
+ }
+
+ DriverDataType type;
+ bool array;
+ if (address.Kind == HeidenhainAddressKind.Standard)
+ {
+ var entry = address.Catalog!;
+ if (declared is { } d && d != entry.DataType)
+ {
+ error = $"'{entry.Address}' publishes {entry.DataType}; dataType {d} does not match.";
+ return false;
+ }
+
+ type = entry.DataType;
+ array = entry.IsArray;
+ if (array && isArray != true)
+ {
+ error = $"'{entry.Address}' is an array leaf — set \"isArray\": true.";
+ return false;
+ }
+ }
+ else
+ {
+ if (declared is not { } d)
+ {
+ error = "PLC and DATA bindings require a dataType (Boolean, Int16, Int32, Int64, Float64 or String).";
+ return false;
+ }
+
+ if (!HeidenhainAddress.CustomDataTypes.Contains(d))
+ {
+ error = d == DriverDataType.Float32
+ ? "Use Float64 for floating bindings."
+ : $"dataType {d} is not supported for bindings; use Boolean, Int16, Int32, Int64, Float64 or String.";
+ return false;
+ }
+
+ if (address.Kind == HeidenhainAddressKind.Plc && HeidenhainAddress.CheckPlcDataType(address.Area!.Value, d) is { } why)
+ {
+ error = why;
+ return false;
+ }
+
+ type = d;
+ array = false;
+ }
+
+ if (!array && isArray == true)
+ {
+ error = $"'{address.Text}' is a scalar — remove \"isArray\": true.";
+ return false;
+ }
+
+ definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array);
+ return true;
+ }
+
+ ///
+ /// Deploy-time inspection: empty when accepts the blob (never stricter),
+ /// otherwise one warning explaining the rejection. Never throws.
+ ///
+ /// The TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string? tagConfig)
+ {
+ if (TryFromTagConfig("inspect", "", tagConfig, out _, out var error)) return [];
+ return [$"Heidenhain TagConfig rejected: {error} The driver will skip this tag (BadNodeIdUnknown)."];
+ }
+
+ /// Parses a data type by NAME; digits-only text is refused (the numeric-enum trap).
+ /// The authored text.
+ /// The parsed type.
+ /// True when names a defined member.
+ public static bool TryParseDataTypeName(string raw, out DriverDataType parsed)
+ {
+ parsed = default;
+ var t = raw.Trim();
+ if (t.Length == 0 || t.All(char.IsAsciiDigit) || t.StartsWith('-')) return false;
+ return Enum.TryParse(t, ignoreCase: true, out parsed) && Enum.IsDefined(parsed);
+ }
+
+ private static string? ReadString(JsonElement root, string name, out bool bad)
+ {
+ bad = false;
+ if (!TryGetProperty(root, name, out var el) || el.ValueKind == JsonValueKind.Null) return null;
+ if (el.ValueKind != JsonValueKind.String)
+ {
+ bad = true;
+ return null;
+ }
+
+ return el.GetString();
+ }
+
+ private static bool TryGetProperty(JsonElement root, string name, out JsonElement value)
+ {
+ foreach (var p in root.EnumerateObject())
+ {
+ if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ value = p.Value;
+ return true;
+ }
+ }
+
+ value = default;
+ return false;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj
new file mode 100644
index 000000000..f0695c1b4
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj
@@ -0,0 +1,15 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ $(NoWarn);CS1591
+
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs
new file mode 100644
index 000000000..3c41e41b3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs
@@ -0,0 +1,116 @@
+using System.Buffers.Binary;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// A binding's value is outside its advertised region / range.
+public sealed class HeidenhainRangeException(string message) : Exception(message);
+
+/// A value cannot be represented in the binding's declared type without truncation or wrap.
+public sealed class HeidenhainTypeMismatchException(string message) : Exception(message);
+
+/// An absolute PLC memory range.
+/// Absolute address.
+/// Byte count.
+public readonly record struct HeidenhainMemoryRange(long Address, int Bytes);
+
+///
+/// The reference's PLC/DATA binding rules (port of HeidenhainBindings): element indexes, byte
+/// capacities, exact lengths, strict little-endian decodes and non-truncating coercion.
+///
+public static class HeidenhainBindingCodec
+{
+ ///
+ /// PLC binding indexes are ELEMENT indexes: multiplied by the native width and added to the advertised
+ /// region base. Capacity is the region's byte count with no second width multiplication.
+ ///
+ /// The area.
+ /// The element index.
+ /// The control's layout.
+ /// The absolute range.
+ public static HeidenhainMemoryRange Range(HeidenhainPlcArea area, long index, Lsv2Layout? layout)
+ {
+ if (layout is null) throw new HeidenhainRangeException("PLC layout unavailable.");
+ var region = layout.Region(area);
+ if (region is null || region.Width is < 1 or > Lsv2Codec.MaxPayload)
+ throw new HeidenhainRangeException("PLC area width unsupported.");
+ var offset = index * region.Width; // index ≤ uint32, width ≤ 247: cannot overflow long
+ if (offset < 0 || offset + region.Width > region.Bytes)
+ throw new HeidenhainRangeException("PLC index outside advertised region.");
+ return new HeidenhainMemoryRange(region.Base + offset, region.Width);
+ }
+
+ /// Decodes PLC bytes for an area and coerces to the declared type.
+ /// The area.
+ /// The bytes read.
+ /// The declared type.
+ /// The value.
+ public static object DecodePlc(HeidenhainPlcArea area, ReadOnlySpan bytes, DriverDataType type)
+ {
+ object value;
+ if (HeidenhainAddress.IsBitArea(area))
+ {
+ Lsv2Codec.Length(bytes, 1);
+ // Raw R_MB Boolean encoding is unverified on real controls (R_MB was denied on all four tested);
+ // the reference accepts only 0/1 here — FF is accepted only for DATA path Booleans.
+ if (bytes[0] is not (0 or 1)) throw new Lsv2DecodeException("Invalid PLC boolean.");
+ value = bytes[0] != 0;
+ }
+ else
+ {
+ value = area switch
+ {
+ HeidenhainPlcArea.Byte => Exact(bytes, 1, b => (int)(sbyte)b[0]),
+ HeidenhainPlcArea.Word => Exact(bytes, 2, b => (int)BinaryPrimitives.ReadInt16LittleEndian(b)),
+ HeidenhainPlcArea.Dword => Exact(bytes, 4, b => BinaryPrimitives.ReadInt32LittleEndian(b)),
+ HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord =>
+ Exact(bytes, 2, b => (int)BinaryPrimitives.ReadUInt16LittleEndian(b)),
+ HeidenhainPlcArea.String => Lsv2Codec.Text(bytes),
+ _ => throw new Lsv2DecodeException("Unknown PLC area."),
+ };
+ }
+
+ return Coerce(value, type);
+ }
+
+ private static object Exact(ReadOnlySpan bytes, int length, ReadOnlySpanFunc decode)
+ {
+ Lsv2Codec.Length(bytes, length);
+ return decode(bytes);
+ }
+
+ private delegate object ReadOnlySpanFunc(ReadOnlySpan bytes);
+
+ ///
+ /// Strict coercion: same-kind Boolean/String pass; numbers convert only exactly (no fractional truncation,
+ /// no wrap, finite only). Anything else is .
+ ///
+ /// The native value.
+ /// The declared type.
+ /// The coerced value.
+ public static object Coerce(object value, DriverDataType type)
+ {
+ if (type == DriverDataType.String && value is string) return value;
+ if (type == DriverDataType.Boolean && value is bool) return value;
+ if (value is int or long or double)
+ {
+ var d = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
+ if (type == DriverDataType.Float64 && double.IsFinite(d)) return d;
+ if (type is DriverDataType.Int16 or DriverDataType.Int32 or DriverDataType.Int64)
+ {
+ if (!double.IsFinite(d) || d != Math.Round(d) || d < long.MinValue || d >= 9.2233720368547758E18)
+ throw new HeidenhainTypeMismatchException("Non-integral or overflowing binding value.");
+ var l = value switch { int i => i, long x => x, _ => (long)d };
+ switch (type)
+ {
+ case DriverDataType.Int64: return l;
+ case DriverDataType.Int32 when l is >= int.MinValue and <= int.MaxValue: return (int)l;
+ case DriverDataType.Int16 when l is >= short.MinValue and <= short.MaxValue: return (short)l;
+ }
+ }
+ }
+
+ throw new HeidenhainTypeMismatchException("Binding datatype mismatch.");
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs
new file mode 100644
index 000000000..1685b3478
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs
@@ -0,0 +1,49 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Resolves password references on demand (each login), so plaintext is never held in options or session
+/// state. Fail-closed: a secret: reference with no resolver, an absent secret, an unset environment
+/// variable or a literal value throws — the reference text is never sent to the control as a password.
+///
+internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func? environment = null)
+{
+ private readonly Func _environment = environment ?? Environment.GetEnvironmentVariable;
+
+ /// The password for a role on a device (device override, then driver-level).
+ public async ValueTask ResolveAsync(HeidenhainDriverOptions options, HeidenhainDeviceOptions device, string role, CancellationToken ct)
+ {
+ var reference = role switch
+ {
+ "INSPECT" => device.InspectPassword ?? options.Access.InspectPassword,
+ "DNC" => device.DncPassword ?? options.Access.DncPassword,
+ "DATA" => device.DataPassword ?? options.Access.DataPassword,
+ _ => throw new InvalidOperationException("Unexpected LSV2 role."),
+ };
+ return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false);
+ }
+
+ /// Resolves one reference.
+ public async ValueTask ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(reference)) return "";
+ if (!HeidenhainOptionsParser.IsCredentialReference(reference))
+ throw new InvalidOperationException($"{role} password must be a secret: or env: reference (fail-closed).");
+ if (reference.StartsWith(HeidenhainOptionsParser.EnvPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var name = reference[HeidenhainOptionsParser.EnvPrefix.Length..].Trim();
+ return _environment(name) is { Length: > 0 } value
+ ? value
+ : throw new InvalidOperationException($"{role} password environment variable '{name}' is not set (fail-closed).");
+ }
+
+ var secret = reference[HeidenhainOptionsParser.SecretPrefix.Length..].Trim();
+ if (resolver is null)
+ throw new InvalidOperationException($"{role} password references secret '{secret}' but no secret store is available (fail-closed).");
+ var resolved = await resolver.GetAsync(new SecretName(secret), ct).ConfigureAwait(false);
+ return !string.IsNullOrEmpty(resolved)
+ ? resolved
+ : throw new InvalidOperationException($"{role} password secret '{secret}' is absent from the store (fail-closed).");
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs
new file mode 100644
index 000000000..fe1003583
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs
@@ -0,0 +1,353 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// Connection state of one controller.
+public enum HeidenhainConnectionState
+{
+ /// Not started (no subscription/read yet) or between generations.
+ Idle,
+
+ /// Opening a session.
+ Connecting,
+
+ /// A session is established and polling.
+ Connected,
+
+ /// The last connect failed or the session was lost; waiting out the backoff.
+ Backoff,
+
+ /// Stopped (driver shutdown).
+ Stopped,
+}
+
+/// Builds the client for an endpoint (test seam).
+internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout);
+
+///
+/// One controller's acquisition engine: a single background loop owns the session, so every LSV2 transaction
+/// for the device is serialized. The loop connects, polls the three cadence groups, publishes into the cache
+/// and, on transport loss, invalidates EVERY remote value of the device at once and reconnects with bounded
+/// exponential backoff (port of the reference HeidenhainAcquisition + HeidenhainDevice).
+///
+///
+/// Generation guard. Each established session is a new generation. Publication checks the
+/// generation under , so a batch that returns from the wire after its session was
+/// retired can never overwrite the outage invalidation. No lock is held across a network read.
+/// Health vs. per-tag quality. A controller refusal, a denial or a malformed record is per-tag
+/// quality and does not degrade the device: only a failed connect or a lost session does. Otherwise a control
+/// that simply denies one selector would latch Degraded with nothing for the comms-loss watchdog to fix, and
+/// the watchdog would reconnect-loop a production control. The reference's "partial data" aggregate is kept
+/// as a diagnostic ().
+///
+internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
+{
+ private readonly HeidenhainDeviceOptions _device;
+ private readonly HeidenhainDriverOptions _options;
+ private readonly IReadOnlyList _tags;
+ private readonly IReadOnlyDictionary> _groups;
+ private readonly HeidenhainSession.PasswordSource _passwords;
+ private readonly Action _publish;
+ private readonly Action _stateChanged;
+ private readonly Lsv2ClientFactory _clientFactory;
+ private readonly TimeProvider _time;
+ private readonly ILogger _logger;
+ private readonly object _publication = new();
+ private readonly object _lifecycle = new();
+ private readonly Dictionary _groupGood = new();
+ private CancellationTokenSource? _cts;
+ private Task? _loop;
+ private HeidenhainSession? _session;
+ private long _generation;
+ private HeidenhainConnectionState _state = HeidenhainConnectionState.Idle;
+ private DateTime _stateChangedUtc;
+ private DateTime? _lastSuccessUtc;
+ private string? _lastError;
+ private int _consecutiveFailures;
+
+ public HeidenhainDeviceRuntime(
+ HeidenhainDeviceOptions device,
+ HeidenhainDriverOptions options,
+ IReadOnlyList tags,
+ HeidenhainSession.PasswordSource passwords,
+ Action publish,
+ Action stateChanged,
+ Lsv2ClientFactory clientFactory,
+ TimeProvider time,
+ ILogger logger)
+ {
+ _device = device;
+ _options = options;
+ _tags = tags;
+ _passwords = passwords;
+ _publish = publish;
+ _stateChanged = stateChanged;
+ _clientFactory = clientFactory;
+ _time = time;
+ _logger = logger;
+ _stateChangedUtc = time.GetUtcNow().UtcDateTime;
+ _groups = tags.GroupBy(t => t.Group).ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList());
+ }
+
+ /// The host:port key used for host status and per-call host resolution.
+ public string HostName => $"{_device.Host}:{_device.Port}";
+
+ /// The /raw Device name.
+ public string DeviceName => _device.DeviceName ?? "";
+
+ /// The device's authored tags.
+ public IReadOnlyList Tags => _tags;
+
+ /// Current connection state.
+ public HeidenhainConnectionState State { get { lock (_publication) return _state; } }
+
+ /// When last changed.
+ public DateTime StateChangedUtc { get { lock (_publication) return _stateChangedUtc; } }
+
+ /// The last time a batch completed with the session alive (liveness evidence for the watchdog).
+ public DateTime? LastSuccessUtc { get { lock (_publication) return _lastSuccessUtc; } }
+
+ /// The last connect/transport error (never a credential).
+ public string? LastError { get { lock (_publication) return _lastError; } }
+
+ /// The reference's poll-group aggregate for this generation: Good / Partial / Lost / Idle.
+ public string DataHealth
+ {
+ get
+ {
+ lock (_publication)
+ {
+ if (_state != HeidenhainConnectionState.Connected) return _state == HeidenhainConnectionState.Idle ? "Idle" : "Lost";
+ if (_groupGood.Count != _groups.Count || _groupGood.Values.Any(g => !g)) return "Partial";
+ return "Good";
+ }
+ }
+ }
+
+ /// The identity of the current session's control, when connected.
+ public string? ControllerModel { get { lock (_publication) return _session?.Identity?.Model; } }
+
+ /// Starts the acquisition loop (idempotent).
+ public void Start()
+ {
+ lock (_lifecycle)
+ {
+ if (_loop is not null) return;
+ _cts = new CancellationTokenSource();
+ var token = _cts.Token;
+ _loop = Task.Run(() => RunAsync(token), CancellationToken.None);
+ }
+ }
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ HeidenhainSession? session = null;
+ try
+ {
+ SetState(HeidenhainConnectionState.Connecting, null);
+ var client = _clientFactory(_device.Host, _device.Port, TimeSpan.FromMilliseconds(_options.TimeoutMs));
+ session = new HeidenhainSession(client, HeidenhainSessionSettings.From(_options, _tags), _passwords);
+ await client.OpenAsync(ct).ConfigureAwait(false);
+ await session.InitializeAsync(ct).ConfigureAwait(false);
+ long generation;
+ lock (_publication)
+ {
+ generation = ++_generation;
+ _session = session;
+ _groupGood.Clear();
+ }
+
+ _consecutiveFailures = 0;
+ SetState(HeidenhainConnectionState.Connected, null);
+ _logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model);
+ await PollAsync(session, generation, ct).ConfigureAwait(false);
+ // Poll returns only on session loss.
+ Retire(generation, "LSV2 communication lost");
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ // Connect / INSPECT / identity failure. Never log payloads or credentials: type + refusal code only.
+ var reason = ex switch
+ {
+ Lsv2RefusalException r => $"INSPECT/identity refused (code {r.Code})",
+ Lsv2DecodeException => "Malformed identity record",
+ _ => ex.Message,
+ };
+ RetireAll(reason);
+ _logger.LogWarning("Heidenhain {Host} connect failed: {Reason}", HostName, reason);
+ }
+ finally
+ {
+ session?.Close();
+ }
+
+ if (ct.IsCancellationRequested) break;
+ _consecutiveFailures++;
+ var delay = ConnectionBackoff.ComputeDelay(
+ TimeSpan.FromMilliseconds(_options.Reconnect.InitialDelayMs), _consecutiveFailures,
+ TimeSpan.FromMilliseconds(_options.Reconnect.MaxDelayMs));
+ SetState(HeidenhainConnectionState.Backoff, null);
+ try { await Task.Delay(delay, _time, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ }
+ }
+
+ private async Task PollAsync(HeidenhainSession session, long generation, CancellationToken ct)
+ {
+ var intervals = new Dictionary
+ {
+ [HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(_options.Polling.StatusMs),
+ [HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(_options.Polling.CustomMs),
+ [HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(_options.Polling.SlowMs),
+ };
+ var start = _time.GetTimestamp();
+ var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero);
+ if (due.Count == 0)
+ {
+ // No tags: keep the session as a liveness check only at the slow cadence via identity.
+ await Task.Delay(Timeout.InfiniteTimeSpan, _time, ct).ConfigureAwait(false);
+ return;
+ }
+
+ while (!ct.IsCancellationRequested)
+ {
+ var elapsed = _time.GetElapsedTime(start);
+ foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow })
+ {
+ if (!due.TryGetValue(group, out var at) || at > elapsed) continue;
+ var tags = _groups[group];
+ var samples = await session.ReadBatchAsync(tags, ct).ConfigureAwait(false);
+ if (!Publish(generation, session, group, tags, samples)) return;
+ due[group] = elapsed + intervals[group];
+ }
+
+ var next = due.Values.Min() - _time.GetElapsedTime(start);
+ if (next > TimeSpan.Zero) await Task.Delay(next, _time, ct).ConfigureAwait(false);
+ }
+ }
+
+ /// Publishes one batch iff its generation is still current. False when the session is lost/retired.
+ private bool Publish(long generation, HeidenhainSession session, HeidenhainPollGroup group,
+ IReadOnlyList tags, IReadOnlyList samples)
+ {
+ var now = _time.GetUtcNow().UtcDateTime;
+ lock (_publication)
+ {
+ if (generation != _generation || !ReferenceEquals(_session, session)) return false;
+ if (session.CommunicationLost) return false;
+ var good = true;
+ for (var i = 0; i < tags.Count; i++)
+ {
+ var s = samples[i];
+ _publish(tags[i].RawPath, new DataValueSnapshot(s.Value, s.Status, now, now));
+ if (tags[i].Address.Catalog?.IsCapability == true) continue;
+ // Explicitly disabled optional timers are not partial data.
+ if (s.Status != HeidenhainStatus.Good
+ && !(tags[i].Address.Catalog?.Feature.StartsWith("Timer_", StringComparison.Ordinal) == true && !_options.Polling.TimersEnabled))
+ good = false;
+ }
+
+ _groupGood[group] = good;
+ _lastSuccessUtc = now;
+ return true;
+ }
+ }
+
+ private void Retire(long generation, string reason)
+ {
+ lock (_publication)
+ {
+ if (generation != _generation) return;
+ }
+
+ RetireAll(reason);
+ _logger.LogWarning("Heidenhain {Host}: {Reason}; reconnecting with backoff", HostName, reason);
+ }
+
+ /// Retires the session and invalidates every remote value (capabilities report CommunicationLost).
+ private void RetireAll(string reason)
+ {
+ HeidenhainSession? old;
+ var now = _time.GetUtcNow().UtcDateTime;
+ lock (_publication)
+ {
+ old = _session;
+ _session = null;
+ _generation++;
+ _groupGood.Clear();
+ _lastError = reason;
+ Invalidate(now);
+ }
+
+ old?.Close();
+ SetState(HeidenhainConnectionState.Backoff, reason);
+ }
+
+ private void Invalidate(DateTime now)
+ {
+ foreach (var tag in _tags)
+ {
+ _publish(tag.RawPath, tag.Address.Catalog?.IsCapability == true
+ ? new DataValueSnapshot(HeidenhainCatalog.States.CommunicationLost, HeidenhainStatus.Good, now, now)
+ : new DataValueSnapshot(null, HeidenhainStatus.BadNoCommunication, now, now));
+ }
+ }
+
+ private void SetState(HeidenhainConnectionState next, string? error)
+ {
+ HeidenhainConnectionState old;
+ lock (_publication)
+ {
+ old = _state;
+ if (error is not null) _lastError = error;
+ if (next == HeidenhainConnectionState.Connected) _lastError = null;
+ if (old == next) return;
+ _state = next;
+ _stateChangedUtc = _time.GetUtcNow().UtcDateTime;
+ }
+
+ try { _stateChanged(this, old, next); }
+ catch (Exception ex) { _logger.LogDebug(ex, "Heidenhain state-change handler threw"); }
+ }
+
+ /// Stops the loop: closes the session first (unblocks a read), cancels, joins ≤ 5 s, invalidates.
+ public async ValueTask DisposeAsync()
+ {
+ Task? loop;
+ CancellationTokenSource? cts;
+ lock (_lifecycle)
+ {
+ loop = _loop;
+ cts = _cts;
+ _loop = null;
+ _cts = null;
+ }
+
+ HeidenhainSession? session;
+ lock (_publication) session = _session;
+ session?.Close();
+ if (cts is not null) await cts.CancelAsync().ConfigureAwait(false);
+ if (loop is not null)
+ {
+ try { await loop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
+ catch (Exception ex) when (ex is TimeoutException or OperationCanceledException) { /* bounded join */ }
+ }
+
+ cts?.Dispose();
+ lock (_publication)
+ {
+ _session = null;
+ _generation++;
+ }
+
+ SetState(HeidenhainConnectionState.Stopped, null);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs
new file mode 100644
index 000000000..f00f58e59
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs
@@ -0,0 +1,412 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Native, READ-ONLY Heidenhain LSV2 driver (lmxopcua#803; protocol/behaviour reference: the ignitionoee
+/// v0.20.3 Java driver). One instance serves N controls, one per /raw Device.
+///
+///
+/// Read-only at three layers. (1) This type does not implement , so the
+/// runtime refuses every OPC write before it reaches the driver; (2) the deploy gate refuses a ReadWrite
+/// access level on any Heidenhain tag, so no node is ever advertised writable; (3) the transport's
+/// admits only logins and read requests, payload-checked, at the last step
+/// before bytes are written.
+/// Cached reads. Acquisition runs on one background loop per control at the configured poll
+/// cadences; and subscriptions are served from the cache, so no network call ever
+/// runs on an OPC UA read thread and subscription sampling cannot exceed the acquisition cadence.
+/// Acquisition starts on the first / , not at
+/// — a throwaway browse instance (the static catalog needs no connection)
+/// therefore never opens a session to a production control.
+/// Comms loss. A lost session publishes BadNoCommunication on every remote value of that
+/// control at once (capability strings read CommunicationLost), marks the driver Degraded with
+/// frozen, and reconnects with bounded exponential backoff;
+/// the first batch of the new generation re-publishes, so recovery is never swallowed by a value-only
+/// dedup (subscriptions dedup on value AND status).
+///
+public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe,
+ IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable
+{
+ /// Most custom (PLC/DATA) bindings one control may carry (reference limit).
+ public const int MaxCustomBindingsPerDevice = 256;
+
+ private readonly string _driverInstanceId;
+ private readonly ILogger _logger;
+ private readonly HeidenhainCredentials _credentials;
+ private readonly Lsv2ClientFactory _clientFactory;
+ private readonly TimeProvider _time;
+ private readonly SkippedTagLedger _skipped = new();
+ private readonly object _lifecycleLock = new();
+ private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _subscriptions = new();
+ private HeidenhainDriverOptions _options;
+ private IReadOnlyDictionary _byRawPath = new Dictionary();
+ private IReadOnlyList _devices = [];
+ private volatile bool _initialized;
+ private long _nextSubscriptionId;
+ private DriverState _state = DriverState.Unknown;
+ private string? _initError;
+
+ /// Production constructor.
+ /// The parsed options.
+ /// The driver instance id.
+ /// Optional logger.
+ /// Resolver for secret: password references.
+ public HeidenhainDriver(HeidenhainDriverOptions options, string driverInstanceId,
+ ILogger? logger = null, ISecretResolver? secretResolver = null)
+ : this(options, driverInstanceId, logger, new HeidenhainCredentials(secretResolver), null, null)
+ {
+ }
+
+ internal HeidenhainDriver(HeidenhainDriverOptions options, string driverInstanceId, ILogger? logger,
+ HeidenhainCredentials credentials, Lsv2ClientFactory? clientFactory, TimeProvider? time)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ _options = options;
+ _driverInstanceId = driverInstanceId;
+ _logger = logger ?? NullLogger.Instance;
+ _credentials = credentials;
+ _clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t));
+ _time = time ?? TimeProvider.System;
+ }
+
+ ///
+ public string DriverInstanceId => _driverInstanceId;
+
+ ///
+ public string DriverType => DriverTypeNames.Heidenhain;
+
+ ///
+ public event EventHandler? OnDataChange;
+
+ ///
+ public event EventHandler? OnHostStatusChanged;
+
+ /// Parses + validates a config document (the one authority the factory and the driver share).
+ /// The config JSON.
+ /// The options.
+ public static HeidenhainDriverOptions ParseOptions(string driverConfigJson)
+ {
+ var options = HeidenhainOptionsParser.Parse(driverConfigJson);
+ var errors = HeidenhainOptionsParser.Validate(options);
+ if (errors.Count > 0) throw new InvalidOperationException("Invalid Heidenhain config: " + string.Join("; ", errors));
+ return options;
+ }
+
+ ///
+ public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ // #630: release prior device state first — a re-init must never leak a session per attempt.
+ await StopDevicesAsync().ConfigureAwait(false);
+ lock (_lifecycleLock) _state = DriverState.Initializing;
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(driverConfigJson) && driverConfigJson.Trim() is not ("{}" or "[]"))
+ _options = ParseOptions(driverConfigJson);
+ BuildTables();
+ lock (_lifecycleLock)
+ {
+ _initError = null;
+ _state = DriverState.Healthy;
+ }
+
+ _initialized = true;
+ }
+ catch (Exception ex)
+ {
+ lock (_lifecycleLock)
+ {
+ _initError = ex.Message;
+ _state = DriverState.Faulted;
+ }
+
+ throw;
+ }
+ }
+
+ private void BuildTables()
+ {
+ var devices = _options.Devices ?? [];
+ _skipped.BeginRebuild(_options.RawTags.Count);
+ var deviceErrors = new List();
+ var perDevice = new Dictionary>();
+ foreach (var d in devices)
+ {
+ var errs = HeidenhainOptionsParser.ValidateDevice(d);
+ if (errs.Count > 0) deviceErrors.Add($"Device '{d.DeviceName ?? d.Host}': {string.Join(" ", errs)}");
+ else perDevice[d] = [];
+ }
+
+ if (deviceErrors.Count > 0) throw new InvalidOperationException(string.Join("; ", deviceErrors));
+
+ foreach (var raw in _options.RawTags)
+ {
+ var device = Route(perDevice.Keys, raw.DeviceName);
+ if (device is null)
+ {
+ _skipped.Skip(raw.RawPath, $"no Heidenhain device named '{raw.DeviceName}'");
+ continue;
+ }
+
+ if (!HeidenhainTagDefinitionFactory.TryFromTagConfig(raw.RawPath, raw.DeviceName, raw.TagConfig, out var def, out var error))
+ {
+ _skipped.Skip(raw.RawPath, error ?? "invalid TagConfig");
+ continue;
+ }
+
+ var list = perDevice[device];
+ if (def.Address.Kind != HeidenhainAddressKind.Standard
+ && list.Count(t => t.Address.Kind != HeidenhainAddressKind.Standard) >= MaxCustomBindingsPerDevice)
+ {
+ _skipped.Skip(raw.RawPath, $"more than {MaxCustomBindingsPerDevice} PLC/DATA bindings on one control");
+ continue;
+ }
+
+ list.Add(def);
+ }
+
+ _skipped.Commit();
+ var runtimes = new List();
+ var byPath = new Dictionary(StringComparer.Ordinal);
+ _cache.Clear();
+ var now = _time.GetUtcNow().UtcDateTime;
+ foreach (var (device, tags) in perDevice)
+ {
+ if (tags.Count == 0) continue; // a control with no authored tags is never contacted
+ var dev = device;
+ var runtime = new HeidenhainDeviceRuntime(
+ dev, _options, tags,
+ (role, ct) => _credentials.ResolveAsync(_options, dev, role, ct),
+ Publish, OnRuntimeState, _clientFactory, _time, _logger);
+ runtimes.Add(runtime);
+ foreach (var t in tags)
+ {
+ byPath[t.RawPath] = runtime;
+ _cache[t.RawPath] = new DataValueSnapshot(null, HeidenhainStatus.BadWaitingForInitialData, null, now);
+ }
+ }
+
+ _devices = runtimes;
+ _byRawPath = byPath;
+ }
+
+ private static HeidenhainDeviceOptions? Route(IEnumerable devices, string deviceName)
+ {
+ var list = devices.ToList();
+ var named = list.FirstOrDefault(d => string.Equals(d.DeviceName, deviceName, StringComparison.Ordinal));
+ if (named is not null) return named;
+ return list.Count == 1 && (string.IsNullOrEmpty(deviceName) || string.IsNullOrEmpty(list[0].DeviceName)) ? list[0] : null;
+ }
+
+ private void EnsureStarted()
+ {
+ if (!_initialized) return;
+ foreach (var d in _devices) d.Start();
+ }
+
+ ///
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
+ InitializeAsync(driverConfigJson, cancellationToken);
+
+ ///
+ public async Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ _initialized = false;
+ await StopDevicesAsync().ConfigureAwait(false);
+ _subscriptions.Clear();
+ lock (_lifecycleLock) _state = DriverState.Unknown;
+ }
+
+ private async Task StopDevicesAsync()
+ {
+ var devices = _devices;
+ _devices = [];
+ _byRawPath = new Dictionary();
+ foreach (var d in devices) await d.DisposeAsync().ConfigureAwait(false);
+ }
+
+ ///
+ public DriverHealth GetHealth()
+ {
+ lock (_lifecycleLock)
+ {
+ if (_state is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown)
+ return new DriverHealth(_state, LastSuccess(), _initError);
+ }
+
+ var devices = _devices;
+ var down = devices.Where(d => d.State is HeidenhainConnectionState.Backoff or HeidenhainConnectionState.Connecting
+ && d.LastError is not null).ToList();
+ var state = down.Count == 0 ? DriverState.Healthy : DriverState.Degraded;
+ var error = down.Count == 0 ? null : string.Join("; ", down.Select(d => $"{d.HostName}: {d.LastError}"));
+ return new DriverHealth(state, LastSuccess(), error);
+ }
+
+ private DateTime? LastSuccess()
+ {
+ DateTime? best = null;
+ foreach (var d in _devices)
+ if (d.LastSuccessUtc is { } t && (best is null || t > best)) best = t;
+ return best;
+ }
+
+ ///
+ public Task> ReadAsync(IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReferences);
+ EnsureStarted();
+ var now = _time.GetUtcNow().UtcDateTime;
+ var result = new DataValueSnapshot[fullReferences.Count];
+ for (var i = 0; i < fullReferences.Count; i++)
+ result[i] = _cache.TryGetValue(fullReferences[i], out var s)
+ ? s
+ : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
+ return Task.FromResult>(result);
+ }
+
+ ///
+ public Task SubscribeAsync(IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReferences);
+ var sub = new Subscription(Interlocked.Increment(ref _nextSubscriptionId), fullReferences.ToHashSet(StringComparer.Ordinal));
+ _subscriptions[sub.Id] = sub;
+ // Initial push of the current cache so the subscriber starts from the driver's real state.
+ var now = _time.GetUtcNow().UtcDateTime;
+ foreach (var r in sub.References)
+ {
+ var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
+ Raise(sub, r, snapshot);
+ }
+
+ EnsureStarted();
+ return Task.FromResult(sub);
+ }
+
+ ///
+ public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
+ {
+ if (handle is Subscription s) _subscriptions.TryRemove(s.Id, out _);
+ return Task.CompletedTask;
+ }
+
+ private void Publish(string rawPath, DataValueSnapshot snapshot)
+ {
+ var changed = true;
+ _cache.AddOrUpdate(rawPath, snapshot, (_, old) =>
+ {
+ changed = old.StatusCode != snapshot.StatusCode || !ValuesEqual(old.Value, snapshot.Value);
+ return snapshot;
+ });
+ if (!changed) return;
+ foreach (var sub in _subscriptions.Values)
+ if (sub.References.Contains(rawPath)) Raise(sub, rawPath, snapshot);
+ }
+
+ private void Raise(Subscription sub, string rawPath, DataValueSnapshot snapshot)
+ {
+ try { OnDataChange?.Invoke(this, new DataChangeEventArgs(sub, rawPath, snapshot)); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain OnDataChange handler threw for {RawPath}", rawPath); }
+ }
+
+ /// Value equality including element-wise array comparison (the (value, status) dedup).
+ internal static bool ValuesEqual(object? a, object? b)
+ {
+ if (a is null || b is null) return a is null && b is null;
+ if (a is Array x && b is Array y)
+ {
+ if (x.Length != y.Length || x.GetType() != y.GetType()) return false;
+ for (var i = 0; i < x.Length; i++)
+ if (!Equals(x.GetValue(i), y.GetValue(i))) return false;
+ return true;
+ }
+
+ return a.Equals(b);
+ }
+
+ private void OnRuntimeState(HeidenhainDeviceRuntime runtime, HeidenhainConnectionState old, HeidenhainConnectionState next)
+ {
+ var o = ToHostState(old);
+ var n = ToHostState(next);
+ if (o == n) return;
+ try { OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(runtime.HostName, o, n)); }
+ catch (Exception ex) { _logger.LogDebug(ex, "Heidenhain host-status handler threw"); }
+ }
+
+ private static HostState ToHostState(HeidenhainConnectionState s) => s switch
+ {
+ HeidenhainConnectionState.Connected => HostState.Running,
+ HeidenhainConnectionState.Backoff => HostState.Stopped,
+ _ => HostState.Unknown,
+ };
+
+ ///
+ public IReadOnlyList GetHostStatuses() =>
+ _devices.Select(d => new HostConnectivityStatus(d.HostName, ToHostState(d.State), d.StateChangedUtc)).ToList();
+
+ ///
+ public string ResolveHost(string fullReference) =>
+ _byRawPath.TryGetValue(fullReference, out var d) ? d.HostName : _driverInstanceId;
+
+ ///
+ public SkippedTagReport? GetSkippedTags() => _skipped.Snapshot();
+
+ ///
+ public IReadOnlyDictionary GetDiagnostics()
+ {
+ var bag = new Dictionary(StringComparer.Ordinal);
+ foreach (var d in _devices)
+ {
+ bag[$"{d.HostName}.state"] = d.State.ToString();
+ bag[$"{d.HostName}.data"] = d.DataHealth;
+ if (d.ControllerModel is { } m) bag[$"{d.HostName}.model"] = m;
+ }
+
+ return DriverDiagnostics.Bound(bag);
+ }
+
+ ///
+ public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
+
+ ///
+ ///
+ /// The namespace is a fixed catalog (no control-dependent path churn), so the browse picker can offer it
+ /// without contacting the control. PLC/DATA bindings are authored by hand: LSV2 has no proven universal
+ /// symbol model and this driver performs no memory scanning.
+ ///
+ public bool SupportsOnlineDiscovery => true;
+
+ ///
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var folders = new Dictionary(StringComparer.Ordinal);
+ foreach (var e in HeidenhainCatalog.Entries)
+ {
+ var slash = e.Address.IndexOf('/');
+ var folderName = e.Address[..slash];
+ var leaf = e.Address[(slash + 1)..];
+ if (!folders.TryGetValue(folderName, out var folder))
+ folders[folderName] = folder = builder.Folder(folderName, folderName);
+ folder.Variable(leaf, leaf, new DriverAttributeInfo(
+ e.Address, e.DataType, e.IsArray, null, SecurityClassification.ViewOnly, IsHistorized: false));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
+
+ private sealed class Subscription(long id, HashSet references) : ISubscriptionHandle
+ {
+ public long Id { get; } = id;
+ public HashSet References { get; } = references;
+ public string DiagnosticId => $"heidenhain-sub-{Id}";
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverFactoryExtensions.cs
new file mode 100644
index 000000000..40a47167e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverFactoryExtensions.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Factory registration for . Construction opens nothing (the discovery browser
+/// builds throwaway instances), there is exactly one config parser (),
+/// and the instance is returned as its concrete type so every optional capability is dispatched — and,
+/// because it implements no , no write is.
+///
+public static class HeidenhainDriverFactoryExtensions
+{
+ /// The DriverInstance.DriverType this factory answers to.
+ public const string DriverTypeName = DriverTypeNames.Heidenhain;
+
+ /// Registers the factory.
+ /// The registry.
+ /// Optional logger factory.
+ /// Resolver for secret: password references.
+ public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null, ISecretResolver? secretResolver = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
+ }
+
+ /// Creates an instance from a config document.
+ /// The instance id.
+ /// The config JSON.
+ /// Optional logger factory.
+ /// Optional secret resolver.
+ /// The driver.
+ public static HeidenhainDriver CreateInstance(string driverInstanceId, string driverConfigJson,
+ ILoggerFactory? loggerFactory = null, ISecretResolver? secretResolver = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
+ var options = HeidenhainDriver.ParseOptions(driverConfigJson);
+ return new HeidenhainDriver(options, driverInstanceId,
+ logger: loggerFactory?.CreateLogger(), secretResolver: secretResolver);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs
new file mode 100644
index 000000000..55f0bdbde
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs
@@ -0,0 +1,71 @@
+using System.Diagnostics;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Test-Connect probe: opens a real LSV2 session to the first configured control, performs the INSPECT
+/// login and reads the version record — the same allowlisted path the driver uses — so a bare TCP listener
+/// never reads as healthy. Nothing else is sent; the socket close ends the session (no logout command).
+///
+///
+/// Registered on admin nodes as well as driver nodes (the Test-Connect singleton is admin-pinned). The
+/// resolver is optional: without one a secret: INSPECT password fails closed with a clear message.
+///
+public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null) : IDriverProbe
+{
+ ///
+ public string DriverType => DriverTypeNames.Heidenhain;
+
+ ///
+ public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
+ {
+ HeidenhainDriverOptions options;
+ try { options = HeidenhainOptionsParser.Parse(configJson); }
+ catch (Exception ex) { return new(false, ex.Message, null); }
+
+ var device = options.Devices.FirstOrDefault();
+ if (device is null || HeidenhainOptionsParser.ValidateDevice(device) is { Count: > 0 })
+ return new(false, "Config has no valid Heidenhain host/port to probe.", null);
+ if (HeidenhainOptionsParser.Validate(options) is { Count: > 0 } errors)
+ return new(false, string.Join("; ", errors), null);
+
+ var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(options.ProbeTimeoutSeconds);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(budget);
+ var sw = Stopwatch.StartNew();
+ var target = $"{device.Host}:{device.Port}";
+ try
+ {
+ string password;
+ try { password = await new HeidenhainCredentials(secretResolver).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); }
+ catch (InvalidOperationException ex) { return new(false, ex.Message, null); }
+
+ var perCall = TimeSpan.FromMilliseconds(Math.Min(options.TimeoutMs, budget.TotalMilliseconds));
+ await using var client = new Lsv2Client(device.Host, device.Port, perCall);
+ await client.OpenAsync(cts.Token).ConfigureAwait(false);
+ await client.LoginAsync("INSPECT", password, cts.Token).ConfigureAwait(false);
+ var versions = Lsv2Data.Versions(await client.ReadVersionAsync(cts.Token).ConfigureAwait(false));
+ sw.Stop();
+ return new(true, $"LSV2 INSPECT session OK at {target}: {versions.Model} NC {versions.Nc}", sw.Elapsed);
+ }
+ catch (OperationCanceledException)
+ {
+ return new(false, $"Probe timed out after {budget.TotalSeconds:F0}s.", null);
+ }
+ catch (Lsv2RefusalException ex)
+ {
+ return new(false, $"Reachable at {target} but the control refused INSPECT/version (code {ex.Code}).", null);
+ }
+ catch (Lsv2DecodeException)
+ {
+ return new(false, $"Reachable at {target} but the version record is malformed — not an LSV2 control?", null);
+ }
+ catch (Lsv2TransportException ex)
+ {
+ return new(false, $"LSV2 session to {target} failed: {ex.Message}", null);
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs
new file mode 100644
index 000000000..de24db277
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs
@@ -0,0 +1,463 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// One acquired leaf: value (null when Bad) + status.
+/// The value, or null.
+/// The OPC UA status code.
+public readonly record struct HeidenhainSample(object? Value, uint Status);
+
+/// What a session needs from the driver configuration.
+internal sealed record HeidenhainSessionSettings(
+ bool TelemetryEnabled,
+ bool DncLoginEnabled,
+ bool DataEnabled,
+ int MaxAlarms,
+ bool TimersEnabled,
+ bool HasPlcBindings,
+ bool HasDataBindings)
+{
+ public static HeidenhainSessionSettings From(HeidenhainDriverOptions o, IReadOnlyCollection tags) => new(
+ o.Access.TelemetryEnabled,
+ o.Access.DncLoginEnabled,
+ o.Access.DataEnabled,
+ o.Polling.MaxAlarms,
+ o.Polling.TimersEnabled,
+ tags.Any(t => t.Address.Kind == HeidenhainAddressKind.Plc),
+ tags.Any(t => t.Address.Kind == HeidenhainAddressKind.DataPath));
+}
+
+///
+/// Generation-local read policy (port of the reference HeidenhainSession): per-batch wire cache,
+/// refused-feature suppression, optional-role login retry and capability strings. There is NO cross-poll Good
+/// cache: every batch reads the wire again. Not thread-safe by design — the owning device loop is the only
+/// caller, which is what serializes controller transactions.
+///
+internal sealed class HeidenhainSession : IAsyncDisposable
+{
+ /// Resolves a role's password on demand; plaintext is never retained in session state.
+ public delegate ValueTask PasswordSource(string role, CancellationToken ct);
+
+ private const uint NotAttempted = uint.MaxValue;
+ private readonly Lsv2Client _client;
+ private readonly HeidenhainSessionSettings _settings;
+ private readonly PasswordSource _passwords;
+ private readonly Dictionary _capabilities = new(StringComparer.Ordinal);
+ private readonly Dictionary _suppressed = new(StringComparer.Ordinal);
+ private Lsv2Layout? _layout;
+ private Lsv2Versions? _identity;
+ private uint _dncStatus = NotAttempted;
+ private uint _dataStatus = NotAttempted;
+ private uint _layoutStatus = NotAttempted;
+ private volatile bool _closed;
+ private volatile bool _lost;
+
+ public HeidenhainSession(Lsv2Client client, HeidenhainSessionSettings settings, PasswordSource passwords)
+ {
+ _client = client;
+ _settings = settings;
+ _passwords = passwords;
+ }
+
+ /// True once a transport failure ended this session.
+ public bool CommunicationLost => _lost || _client.IsClosed;
+
+ /// The identity read at initialize.
+ public Lsv2Versions? Identity => _identity;
+
+ ///
+ /// INSPECT login + version (required; a failure fails the connect), then the optional DNC/DATA logins and
+ /// the PLC layout when configured bindings need them. Optional refusals never fail the session.
+ ///
+ public async Task InitializeAsync(CancellationToken ct)
+ {
+ await _client.LoginAsync("INSPECT", await _passwords("INSPECT", ct).ConfigureAwait(false), ct).ConfigureAwait(false);
+ _identity = Lsv2Data.Versions(await _client.ReadVersionAsync(ct).ConfigureAwait(false));
+ _capabilities[HeidenhainCatalog.IdentityFeature] = HeidenhainCatalog.States.Supported;
+ if (_settings.TelemetryEnabled && _settings.DncLoginEnabled) _dncStatus = await OptionalLoginAsync("DNC", ct).ConfigureAwait(false);
+ if (_settings.DataEnabled && _settings.HasDataBindings)
+ {
+ // Reference-derived DATA support is iTNC only.
+ _dataStatus = _identity.Model.Contains("itnc", StringComparison.OrdinalIgnoreCase)
+ ? await OptionalLoginAsync("DATA", ct).ConfigureAwait(false)
+ : HeidenhainStatus.BadNotSupported;
+ }
+
+ if (_settings.HasPlcBindings)
+ {
+ try
+ {
+ _layout = Lsv2Data.Layout(await _client.ReadParametersAsync(ct).ConfigureAwait(false));
+ _layoutStatus = HeidenhainStatus.Good;
+ }
+ catch (Lsv2RefusalException e) { _layoutStatus = HeidenhainStatus.FromRefusal(e.Code); }
+ catch (Lsv2DecodeException) { _layoutStatus = HeidenhainStatus.BadDecodingError; }
+ }
+ }
+
+ private async Task OptionalLoginAsync(string role, CancellationToken ct)
+ {
+ try
+ {
+ await _client.LoginAsync(role, await _passwords(role, ct).ConfigureAwait(false), ct).ConfigureAwait(false);
+ return HeidenhainStatus.Good;
+ }
+ catch (Lsv2RefusalException e)
+ {
+ return HeidenhainStatus.FromRefusal(e.Code);
+ }
+ }
+
+ ///
+ /// Reads one batch. Shared-selector replies are fetched once per batch; aliases of a custom address share
+ /// one wire read. On transport loss every remote leaf of the batch is BadNoCommunication and
+ /// becomes true.
+ ///
+ public async Task> ReadBatchAsync(IReadOnlyList tags, CancellationToken ct)
+ {
+ var outcomes = new Dictionary(StringComparer.Ordinal);
+ var wire = new Dictionary(StringComparer.Ordinal);
+ var result = new HeidenhainSample[tags.Count];
+ for (var i = 0; i < tags.Count; i++)
+ {
+ var tag = tags[i];
+ if (tag.Address.Catalog?.IsCapability == true) continue;
+ var key = OutcomeKey(tag);
+ if (!outcomes.TryGetValue(key, out var o))
+ {
+ o = await AcquireAsync(tag, wire, ct).ConfigureAwait(false);
+ outcomes[key] = o;
+ }
+
+ var status = o.Status;
+ object? value = null;
+ if (status == HeidenhainStatus.Good)
+ {
+ try
+ {
+ value = Leaf(tag, o.Value);
+ if (value is null) status = HeidenhainStatus.BadNotSupported;
+ }
+ catch (HeidenhainTypeMismatchException) { status = HeidenhainStatus.BadTypeMismatch; }
+ }
+
+ result[i] = new HeidenhainSample(value, status);
+ }
+
+ var dead = _closed || CommunicationLost;
+ for (var i = 0; i < tags.Count; i++)
+ {
+ var tag = tags[i];
+ if (tag.Address.Catalog?.IsCapability == true)
+ result[i] = new HeidenhainSample(dead ? HeidenhainCatalog.States.CommunicationLost : Capability(tag.Address.Catalog.Feature), HeidenhainStatus.Good);
+ else if (dead)
+ result[i] = new HeidenhainSample(null, HeidenhainStatus.BadNoCommunication);
+ }
+
+ return result;
+ }
+
+ private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind == HeidenhainAddressKind.Standard
+ ? tag.Address.Catalog!.Feature
+ : "Custom|" + tag.Address.Text + "|" + tag.DataType;
+
+ /// The current capability string for a feature.
+ public string Capability(string feature)
+ {
+ switch (feature)
+ {
+ case HeidenhainCatalog.DncLoginFeature:
+ if (!_settings.TelemetryEnabled || !_settings.DncLoginEnabled) return HeidenhainCatalog.States.Disabled;
+ return LoginState(_dncStatus);
+ case HeidenhainCatalog.DataLoginFeature:
+ if (!_settings.DataEnabled || !_settings.HasDataBindings) return HeidenhainCatalog.States.Disabled;
+ return LoginState(_dataStatus);
+ case HeidenhainCatalog.PlcLayoutFeature:
+ if (!_settings.HasPlcBindings) return HeidenhainCatalog.States.Disabled;
+ return LoginState(_layoutStatus);
+ }
+
+ if (_capabilities.TryGetValue(feature, out var s)) return s;
+ if (feature.StartsWith("Timer_", StringComparison.Ordinal) && !_settings.TimersEnabled) return HeidenhainCatalog.States.Disabled;
+ if (feature != HeidenhainCatalog.IdentityFeature && !_settings.TelemetryEnabled) return HeidenhainCatalog.States.Disabled;
+ return HeidenhainCatalog.States.Unknown;
+ }
+
+ private static string LoginState(uint status) => status switch
+ {
+ NotAttempted => HeidenhainCatalog.States.Unknown,
+ HeidenhainStatus.Good => HeidenhainCatalog.States.Supported,
+ _ => HeidenhainStatus.State(status),
+ };
+
+ private async Task AcquireAsync(HeidenhainTagDefinition tag, Dictionary wire, CancellationToken ct)
+ {
+ if (_closed || CommunicationLost) return new Outcome(null, HeidenhainStatus.BadNoCommunication);
+ var isCustom = tag.Address.Kind != HeidenhainAddressKind.Standard;
+ var feature = isCustom ? tag.Address.Text : tag.Address.Catalog!.Feature;
+ var suppressKey = isCustom ? "Custom|" + tag.Address.Text : feature;
+ if (_suppressed.TryGetValue(suppressKey, out var suppressedStatus)) return new Outcome(null, suppressedStatus);
+ try
+ {
+ object value;
+ if (isCustom)
+ {
+ value = await CustomAsync(tag, wire, ct).ConfigureAwait(false);
+ }
+ else if (feature == HeidenhainCatalog.IdentityFeature)
+ {
+ value = Lsv2Data.Versions(await WireAsync(wire, "Identity", c => _client.ReadVersionAsync(c), ct).ConfigureAwait(false));
+ }
+ else
+ {
+ if (feature.StartsWith("Timer_", StringComparison.Ordinal) && !_settings.TimersEnabled)
+ throw new QualityException(HeidenhainStatus.BadNotSupported, HeidenhainCatalog.States.Disabled);
+ await PrepareTelemetryAsync(wire, ct).ConfigureAwait(false);
+ value = feature switch
+ {
+ "Execution" => Lsv2Data.U16(await _client.ReadRuntimeAsync(23, ct).ConfigureAwait(false)),
+ "ProgramState" => Lsv2Data.U16(await _client.ReadRuntimeAsync(26, ct).ConfigureAwait(false)),
+ "Program" => Lsv2Data.Program(await _client.ReadRuntimeAsync(24, ct).ConfigureAwait(false)),
+ "Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)),
+ "Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)),
+ "Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)),
+ "Alarms" => await AlarmsAsync(ct).ConfigureAwait(false),
+ _ => await TimerAsync(feature, ct).ConfigureAwait(false),
+ };
+ }
+
+ if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.Supported;
+ return new Outcome(value, HeidenhainStatus.Good);
+ }
+ catch (Lsv2RefusalException e)
+ {
+ var s = HeidenhainStatus.FromRefusal(e.Code);
+ // A control requiring DNC may deny a selector while its optional role login is temporarily
+ // unavailable. Keep retrying that feature so a later login success can recover it; stable
+ // per-selector denials still suppress until reconnect.
+ var provisionalDncDenial = s == HeidenhainStatus.BadUserAccessDenied && !isCustom
+ && feature != HeidenhainCatalog.IdentityFeature
+ && _dncStatus != HeidenhainStatus.Good && _dncStatus != NotAttempted && !HeidenhainStatus.IsStable(_dncStatus);
+ if (s == HeidenhainStatus.BadNotSupported || (s == HeidenhainStatus.BadUserAccessDenied && !provisionalDncDenial))
+ _suppressed[suppressKey] = s;
+ if (!isCustom) _capabilities[feature] = HeidenhainStatus.State(s);
+ return new Outcome(null, s);
+ }
+ catch (QualityException e)
+ {
+ if (!isCustom) _capabilities[feature] = e.State;
+ return new Outcome(null, e.Status);
+ }
+ catch (Lsv2DecodeException)
+ {
+ if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.InvalidData;
+ return new Outcome(null, HeidenhainStatus.BadDecodingError);
+ }
+ catch (HeidenhainTypeMismatchException)
+ {
+ return new Outcome(null, HeidenhainStatus.BadTypeMismatch);
+ }
+ catch (HeidenhainRangeException)
+ {
+ if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.InvalidData;
+ return new Outcome(null, HeidenhainStatus.BadOutOfRange);
+ }
+ catch (Lsv2TransportException)
+ {
+ _lost = true;
+ _client.Close();
+ return new Outcome(null, HeidenhainStatus.BadNoCommunication);
+ }
+ }
+
+ private async Task TimerAsync(string feature, CancellationToken ct)
+ {
+ var index = -1;
+ for (var i = 0; i < HeidenhainCatalog.TimerNames.Count; i++)
+ if (HeidenhainCatalog.TimerFeature(HeidenhainCatalog.TimerNames[i]) == feature) index = i;
+ if (index < 0) throw new QualityException(HeidenhainStatus.BadNotSupported, HeidenhainCatalog.States.Unsupported);
+ return Lsv2Data.U32(await _client.ReadRuntimeAsync(30 + index, ct).ConfigureAwait(false));
+ }
+
+ private async Task