From d50ee809e32d1fcc700bd05bab0680f16ffe6739 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:08:30 -0500 Subject: [PATCH] feat(heidenhain): native read-only LSV2 driver + in-process simulator (#803) Native LSV2 TCP transport (default port 19000; no Java/Python/vendor SDK), ported from the ignitionoee v0.20.3 reference driver: - Lsv2ReadOnlyPolicy: payload-aware outbound allowlist (A_LG INSPECT/DNC/DATA, R_VR, R_PR, R_RI 22-28/30-34/51, bounded R_MB, R_DP), applied in Lsv2Codec.Frame before any byte is written; no public raw-command API. - Lsv2Client: serialized transactions, one monotonic deadline per transaction, EOF/oversize/ unexpected/trickle close the client; Close never waits on the gate. - Lsv2Data / HeidenhainBindingCodec: reference decoders, R_PR byte-capacity + element-index rules, next-area cap, DATA Boolean 00/01/FF, strict non-truncating coercion. - HeidenhainSession: per-batch wire cache, refusal suppression, provisional DNC denial retry, alarm cursor (57 = end, overflow = BadOutOfRange), capability strings. - HeidenhainDeviceRuntime: one loop per control, generation-guarded publication, all-node BadNoCommunication on comms loss, bounded exponential reconnect. - HeidenhainDriver: cached ReadAsync, push subscriptions deduped on (value,status), static catalog discovery (no connect), host status, diagnostics, skipped-tag report. No IWritable. - Probe (INSPECT login + version) and factory registered in DriverFactoryBootstrap (probe on admin nodes too); DriverTypeNames.Heidenhain. Tests: 159 unit tests against an in-process LSV2 simulator incl. the Java fixtures' captured iTNC 530 replies as golden vectors; env-gated LiveIntegration category for real hardware. --- ZB.MOM.WW.OtOpcUa.slnx | 3 + .../DriverTypeNames.cs | 4 + .../HeidenhainAddress.cs | 225 +++++++++ .../HeidenhainCatalog.cs | 125 +++++ .../HeidenhainDriverOptions.cs | 128 +++++ .../HeidenhainOptionsParser.cs | 111 +++++ .../HeidenhainTagDefinitionFactory.cs | 230 +++++++++ ...OtOpcUa.Driver.Heidenhain.Contracts.csproj | 15 + .../HeidenhainBindingCodec.cs | 116 +++++ .../HeidenhainCredentials.cs | 49 ++ .../HeidenhainDeviceRuntime.cs | 353 +++++++++++++ .../HeidenhainDriver.cs | 412 ++++++++++++++++ .../HeidenhainDriverFactoryExtensions.cs | 44 ++ .../HeidenhainDriverProbe.cs | 71 +++ .../HeidenhainSession.cs | 463 ++++++++++++++++++ .../HeidenhainStatus.cs | 64 +++ .../Lsv2/Lsv2Client.cs | 203 ++++++++ .../Lsv2/Lsv2Codec.cs | 87 ++++ .../Lsv2/Lsv2Data.cs | 231 +++++++++ .../Lsv2/Lsv2ReadOnlyPolicy.cs | 134 +++++ ...ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj | 29 ++ .../Drivers/DriverFactoryBootstrap.cs | 9 + .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + ....WW.OtOpcUa.Core.Abstractions.Tests.csproj | 1 + .../Fixtures/itnc530-inspect.properties | 20 + .../Fixtures/plc-count-units.properties | 180 +++++++ .../HeidenhainConfigTests.cs | 163 ++++++ .../HeidenhainDriverTests.cs | 306 ++++++++++++ .../HeidenhainLiveIntegrationTests.cs | 88 ++++ .../HeidenhainPlcUnitsTests.cs | 76 +++ .../HeidenhainSessionTests.cs | 408 +++++++++++++++ .../Lsv2DataTests.cs | 183 +++++++ .../Lsv2ReadOnlyPolicyTests.cs | 143 ++++++ .../Lsv2TransportTests.cs | 146 ++++++ .../Simulator/Lsv2Simulator.cs | 197 ++++++++ .../Simulator/SimulatedControl.cs | 121 +++++ .../TestTags.cs | 26 + ....WW.OtOpcUa.Driver.Heidenhain.Tests.csproj | 40 ++ 38 files changed, 5205 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverFactoryExtensions.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Client.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2ReadOnlyPolicy.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/itnc530-inspect.properties create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/plc-count-units.properties create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainConfigTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPlcUnitsTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainSessionTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2ReadOnlyPolicyTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2TransportTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/Lsv2Simulator.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/TestTags.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.csproj 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 CustomAsync(HeidenhainTagDefinition tag, Dictionary wire, CancellationToken ct) + { + var a = tag.Address; + if (a.Kind == HeidenhainAddressKind.DataPath) + { + await RequireLoginAsync("DATA", wire, ct).ConfigureAwait(false); + var bytes = await WireAsync(wire, "DATA:" + a.DataPath, c => _client.ReadDataPathAsync(a.DataPath!, c), ct).ConfigureAwait(false); + return HeidenhainBindingCodec.Coerce(Lsv2Data.DataPath(bytes), tag.DataType); + } + + if (_layoutStatus != HeidenhainStatus.Good) + { + if (HeidenhainStatus.IsStable(_layoutStatus) && _layoutStatus != NotAttempted) + throw new QualityException(_layoutStatus, HeidenhainStatus.State(_layoutStatus)); + try + { + _layout = Lsv2Data.Layout(await WireAsync(wire, "Layout", c => _client.ReadParametersAsync(c), ct).ConfigureAwait(false)); + _layoutStatus = HeidenhainStatus.Good; + } + catch (Lsv2RefusalException e) + { + _layoutStatus = HeidenhainStatus.FromRefusal(e.Code); + throw new QualityException(_layoutStatus, HeidenhainStatus.State(_layoutStatus)); + } + catch (Lsv2DecodeException) + { + _layoutStatus = HeidenhainStatus.BadDecodingError; + throw; + } + } + + var range = HeidenhainBindingCodec.Range(a.Area!.Value, a.Index, _layout); + var raw = await WireAsync(wire, "PLC:" + range.Address + ":" + range.Bytes, c => _client.ReadMemoryAsync(range.Address, range.Bytes, c), ct).ConfigureAwait(false); + if (raw.Length != range.Bytes) throw new Lsv2DecodeException("PLC reply length mismatch."); + return HeidenhainBindingCodec.DecodePlc(a.Area.Value, raw, tag.DataType); + } + + private async Task RequireLoginAsync(string role, Dictionary wire, CancellationToken ct) + { + var dnc = role == "DNC"; + var status = dnc ? _dncStatus : _dataStatus; + if (status == HeidenhainStatus.Good) return; + var enabled = dnc ? _settings.TelemetryEnabled && _settings.DncLoginEnabled : _settings.DataEnabled; + var effective = status == NotAttempted ? HeidenhainStatus.BadNotSupported : status; + if (!enabled || status == NotAttempted || HeidenhainStatus.IsStable(effective)) + throw new QualityException(effective, enabled ? HeidenhainStatus.State(effective) : HeidenhainCatalog.States.Disabled); + try + { + await WireAsync(wire, "Login_" + role, async c => + { + string password; + try { password = await _passwords(role, c).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new QualityException(HeidenhainStatus.BadUnexpectedError, HeidenhainCatalog.States.Unknown); + } + + await _client.LoginAsync(role, password, c).ConfigureAwait(false); + return []; + }, ct).ConfigureAwait(false); + if (dnc) _dncStatus = HeidenhainStatus.Good; + else _dataStatus = HeidenhainStatus.Good; + } + catch (Lsv2RefusalException e) + { + if (dnc) _dncStatus = HeidenhainStatus.FromRefusal(e.Code); + else _dataStatus = HeidenhainStatus.FromRefusal(e.Code); + throw; + } + } + + private async Task PrepareTelemetryAsync(Dictionary wire, CancellationToken ct) + { + if (!_settings.TelemetryEnabled) throw new QualityException(HeidenhainStatus.BadNotSupported, HeidenhainCatalog.States.Disabled); + // Real iTNC 530 controls serve several selectors under INSPECT despite denying DNC. Retrying a + // temporary optional-login failure is useful, but only each read reply determines that feature's + // access. A transport failure still ends the session (it propagates). + if (_dncStatus != HeidenhainStatus.Good && _dncStatus != NotAttempted && !HeidenhainStatus.IsStable(_dncStatus)) + { + try { await RequireLoginAsync("DNC", wire, ct).ConfigureAwait(false); } + catch (Lsv2RefusalException) { /* issue the allowlisted read anyway */ } + catch (QualityException) { /* issue the allowlisted read anyway */ } + } + } + + private async Task> AlarmsAsync(CancellationToken ct) + { + var records = new List(); + var selector = 27; + while (true) + { + byte[] bytes; + try { bytes = await _client.ReadRuntimeAsync(selector, ct).ConfigureAwait(false); } + catch (Lsv2RefusalException e) when (e.Code == 57) { return records; } // only 57 proves end/empty + + if (records.Count >= _settings.MaxAlarms) + throw new QualityException(HeidenhainStatus.BadOutOfRange, HeidenhainCatalog.States.InvalidData); + records.Add(Lsv2Data.Alarm(bytes)); + selector = 28; + } + } + + private static object? Leaf(HeidenhainTagDefinition tag, object? value) + { + if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value; + var address = tag.Address.Catalog!.Address; + var name = address[(address.IndexOf('/') + 1)..]; + switch (value) + { + case Lsv2Versions v: + return name switch { "Model" => v.Model, "NcVersion" => v.Nc, "PlcVersion" => v.Plc, _ => v.Options }; + case Lsv2Program p: + return name switch { "Main" => p.Main, "Current" => p.Current, _ => (object)p.Line }; + case double[] o when address.StartsWith("Overrides/", StringComparison.Ordinal): + return o[name switch { "Feed" => 0, "Spindle" => 1, _ => 2 }]; + case Lsv2Axes a: + return name == "Names" ? a.Names : a.Positions; + case Lsv2Tool t: + return name switch + { + "Number" => t.Number, + "Index" => t.Index, + "AxisCode" => t.Axis, + "Length" => t.Length, + _ => t.Radius, + }; + case List list: + return name switch + { + "Count" => list.Count, + "Messages" => list.Select(x => x.Message).ToArray(), + "Classes" => list.Select(x => x.ErrorClass).ToArray(), + "Groups" => list.Select(x => x.Group).ToArray(), + _ => list.Select(x => x.Number).ToArray(), + }; + case int i: + return i; + case long l: + return l; + default: + throw new HeidenhainTypeMismatchException("Unexpected leaf value."); + } + } + + private static async Task WireAsync(Dictionary wire, string key, Func> request, CancellationToken ct) + { + if (!wire.TryGetValue(key, out var cached)) + { + try { cached = new Raw(await request(ct).ConfigureAwait(false), null); } + catch (Exception ex) when (ex is Lsv2RefusalException or QualityException or Lsv2TransportException) { cached = new Raw(null, ex); } + wire[key] = cached; + } + + if (cached.Error is { } error) System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(error); + return cached.Bytes!; + } + + public ValueTask DisposeAsync() + { + _closed = true; + _client.Close(); + return ValueTask.CompletedTask; + } + + /// Closes the client immediately (interrupts a blocked read without waiting). + public void Close() + { + _closed = true; + _client.Close(); + } + + private readonly record struct Outcome(object? Value, uint Status); + + private readonly record struct Raw(byte[]? Bytes, Exception? Error); + + private sealed class QualityException(uint status, string state) : Exception(state) + { + public uint Status { get; } = status; + public string State { get; } = state; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs new file mode 100644 index 000000000..cc2182790 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs @@ -0,0 +1,64 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// +/// The OPC UA status codes this driver publishes (canonical Opc.Ua.StatusCodes numerics, pinned by +/// HeidenhainStatusTests), with the reference's failure interpretation. +/// +public static class HeidenhainStatus +{ + public const uint Good = 0u; + + /// Transport lost — every remote leaf, immediately. + public const uint BadNoCommunication = 0x80310000u; + + /// Refusal 22/24, disabled feature, or a feature absent from the reply (8-byte tool geometry). + public const uint BadNotSupported = 0x803D0000u; + + /// Refusal 23/49. + public const uint BadUserAccessDenied = 0x801F0000u; + + /// Malformed complete record. + public const uint BadDecodingError = 0x80070000u; + + /// Binding type mismatch. + public const uint BadTypeMismatch = 0x80740000u; + + /// Out-of-region binding, or alarm-list overflow. + public const uint BadOutOfRange = 0x803C0000u; + + /// Any other controller refusal (retried next poll). + public const uint BadUnexpectedError = 0x80010000u; + + /// A RawPath this driver has no authored tag for. + public const uint BadNodeIdUnknown = 0x80340000u; + + /// Authored but not yet acquired. + public const uint BadWaitingForInitialData = 0x80320000u; + + /// Maps a controller refusal code to a value status (22/24 unsupported, 23/49 denied, else unexpected). + /// The refusal code. + /// The status. + public static uint FromRefusal(int code) => code switch + { + 22 or 24 => BadNotSupported, + 23 or 49 => BadUserAccessDenied, + _ => BadUnexpectedError, + }; + + /// True for the stable refusals that may be suppressed for the rest of a session. + /// The status. + /// Whether it is stable. + public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied; + + /// The capability state for a failure status. + /// The status. + /// The state string. + public static string State(uint status) => status switch + { + BadUserAccessDenied => HeidenhainCatalog.States.AccessDenied, + BadNotSupported => HeidenhainCatalog.States.Unsupported, + BadDecodingError or BadTypeMismatch or BadOutOfRange => HeidenhainCatalog.States.InvalidData, + BadNoCommunication => HeidenhainCatalog.States.CommunicationLost, + _ => HeidenhainCatalog.States.Unknown, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Client.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Client.cs new file mode 100644 index 000000000..ab640a3e4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Client.cs @@ -0,0 +1,203 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +/// +/// One serialized native LSV2 TCP session (port of the reference Lsv2Client). Exactly one transaction +/// is on the wire at a time; never waits on the transaction gate, so it always interrupts a +/// blocked read. +/// +/// +/// The public surface is the read-only surface. There is no method taking a command string: the +/// only requests are the typed login/read methods below, and the private exchange routes every frame through +/// , which applies before any byte is +/// written. +/// Framing discipline. Each transaction gets ONE monotonic deadline covering write + header + +/// body, so a peer trickling single bytes cannot extend it. EOF, deadline, an oversized or unexpected frame, +/// a malformed refusal, or any socket error closes the client: a later request can never consume bytes left +/// over from a failed transaction. A complete T_ER/T_BD refusal leaves the stream synchronised +/// and surfaces as without closing. +/// +public sealed class Lsv2Client : IAsyncDisposable +{ + private readonly string _host; + private readonly int _port; + private readonly TimeSpan _timeout; + private readonly SemaphoreSlim _gate = new(1, 1); + private Socket? _socket; + private NetworkStream? _stream; + private volatile bool _closed; + private int _opened; + + /// Creates an unopened client. + /// The control host. + /// The LSV2 port. + /// Connect + per-transaction deadline. + public Lsv2Client(string host, int port, TimeSpan timeout) + { + if (string.IsNullOrWhiteSpace(host) || port is < 1 or > 65535 || timeout <= TimeSpan.Zero) + throw new ArgumentException("Invalid LSV2 endpoint/timeout."); + _host = host; + _port = port; + _timeout = timeout; + } + + /// True once the client has been closed (explicitly or by a transport failure). + public bool IsClosed => _closed; + + /// Total request frames written (diagnostics/tests). + public long RequestsSent => Interlocked.Read(ref _requestsSent); + + private long _requestsSent; + + /// Connects, bounded by the configured timeout. + /// Cancellation. + public async Task OpenAsync(CancellationToken ct) + { + if (Interlocked.Exchange(ref _opened, 1) != 0 || _closed) + throw new Lsv2TransportException("LSV2 client is not new."); + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + _socket = socket; + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + deadline.CancelAfter(_timeout); + try + { + if (_closed) throw new Lsv2TransportException("LSV2 client closed."); + await socket.ConnectAsync(_host, _port, deadline.Token).ConfigureAwait(false); + if (_closed) throw new Lsv2TransportException("LSV2 client closed."); + _stream = new NetworkStream(socket, ownsSocket: false); + } + catch (Exception ex) when (ex is not Lsv2TransportException) + { + Close(); + if (ct.IsCancellationRequested) throw; + throw new Lsv2TransportException( + ex is OperationCanceledException ? "LSV2 connect timed out." : $"LSV2 connect failed: {ex.Message}", ex); + } + catch + { + Close(); + throw; + } + } + + /// A_LG → T_OK. Only INSPECT/DNC/DATA are accepted by the policy. + /// The role. + /// The resolved password, or empty. + /// Cancellation. + public Task LoginAsync(string role, string password, CancellationToken ct) => + ExchangeAsync("A_LG", Lsv2ReadOnlyPolicy.LoginPayload(role, password), "T_OK", ct); + + /// R_VR → S_VR combined version record. + /// Cancellation. + /// The raw record. + public Task ReadVersionAsync(CancellationToken ct) => ExchangeAsync("R_VR", [], "S_VR", ct); + + /// R_PR → S_PR system/memory-layout record. + /// Cancellation. + /// The raw record. + public Task ReadParametersAsync(CancellationToken ct) => ExchangeAsync("R_PR", [], "S_PR", ct); + + /// R_RI → S_RI runtime information for an allowlisted selector. + /// The selector. + /// Cancellation. + /// The raw record. + public Task ReadRuntimeAsync(int selector, CancellationToken ct) => + ExchangeAsync("R_RI", Lsv2ReadOnlyPolicy.RuntimePayload(selector), "S_RI", ct); + + /// R_MB → S_MB PLC memory bytes. + /// Absolute address. + /// Byte count (1–247). + /// Cancellation. + /// The raw bytes. + public Task ReadMemoryAsync(long address, int count, CancellationToken ct) => + ExchangeAsync("R_MB", Lsv2ReadOnlyPolicy.MemoryPayload(address, count), "S_MB", ct); + + /// R_DP → S_DP data-path value. + /// The data path. + /// Cancellation. + /// The raw record. + public Task ReadDataPathAsync(string path, CancellationToken ct) => + ExchangeAsync("R_DP", Lsv2ReadOnlyPolicy.DataPathPayload(path), "S_DP", ct); + + /// + /// The single transaction primitive. internal (not public) and still policy-checked: the frame is + /// built — and refused — before the gate is taken or the socket is touched. + /// + internal async Task ExchangeAsync(string command, byte[] payload, string expected, CancellationToken ct) + { + var request = Lsv2Codec.Frame(command, payload); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var stream = _stream; + if (_closed || stream is null) throw new Lsv2TransportException("LSV2 session closed."); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + deadline.CancelAfter(_timeout); + try + { + Interlocked.Increment(ref _requestsSent); + await stream.WriteAsync(request, deadline.Token).ConfigureAwait(false); + var header = new byte[Lsv2Codec.HeaderSize]; + await stream.ReadExactlyAsync(header, deadline.Token).ConfigureAwait(false); + var length = BinaryPrimitives.ReadUInt32BigEndian(header); + if (length > Lsv2Codec.MaxPayload) throw new Lsv2TransportException("Oversized LSV2 response."); + var response = Encoding.ASCII.GetString(header, 4, 4); + var body = new byte[length]; + await stream.ReadExactlyAsync(body, deadline.Token).ConfigureAwait(false); + if (response is "T_ER" or "T_BD") + { + if (body.Length != 2) throw new Lsv2TransportException("Malformed LSV2 refusal."); + throw new Lsv2RefusalException(body[0], body[1]); + } + + if (!string.Equals(response, expected, StringComparison.Ordinal)) + throw new Lsv2TransportException("Unexpected LSV2 response type."); + if (expected == "T_OK" && body.Length != 0) throw new Lsv2TransportException("Malformed LSV2 T_OK."); + return body; + } + catch (Lsv2RefusalException) + { + throw; + } + catch (Lsv2TransportException) + { + Close(); + throw; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + Close(); // the stream position is unknown — never reuse it + throw; + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException or EndOfStreamException) + { + Close(); + throw new Lsv2TransportException( + ex is OperationCanceledException ? "LSV2 transaction deadline exceeded." : $"LSV2 transport failure: {ex.GetType().Name}", ex); + } + } + finally + { + _gate.Release(); + } + } + + /// Closes the socket without waiting for an in-flight transaction (which then fails). + public void Close() + { + _closed = true; + var socket = _socket; + try { _stream?.Dispose(); } catch { /* already unavailable */ } + try { socket?.Dispose(); } catch { /* already unavailable */ } + } + + /// + public ValueTask DisposeAsync() + { + Close(); + return ValueTask.CompletedTask; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs new file mode 100644 index 000000000..97e3645df --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs @@ -0,0 +1,87 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +/// A complete, malformed controller record (wrong length, missing terminator, NaN, …). +public sealed class Lsv2DecodeException(string message) : Exception(message); + +/// +/// A complete controller refusal frame (T_ER/T_BD). Unlike a transport failure it leaves the +/// framing synchronised, so the session stays usable. +/// +public sealed class Lsv2RefusalException(int type, int code) + : Exception($"LSV2 controller refusal type={type} code={code}") +{ + /// The refusal type byte. + public int Type { get; } = type; + + /// The refusal code byte (22/24 unsupported, 23/49 access denied, 57 no next error, …). + public int Code { get; } = code; +} + +/// +/// A transport failure (EOF, deadline, oversized/unexpected frame, socket error). The client has been closed; +/// the session is over. +/// +public sealed class Lsv2TransportException(string message, Exception? inner = null) : IOException(message, inner); + +/// Legacy LSV2 framing and Latin-1 primitives. No buffer negotiation is performed. +public static class Lsv2Codec +{ + /// Frame header size: uint32be payload length + 4 ASCII command bytes. + public const int HeaderSize = 8; + + /// Legacy maximum payload (255-byte frame − header). + public const int MaxPayload = HeidenhainAddress.MaxPayload; + + /// + /// Builds a request frame. The read-only policy is applied HERE, the last step before bytes exist, so no + /// code path can produce an unchecked frame. + /// + /// The command. + /// The payload. + /// The frame bytes. + public static byte[] Frame(string command, ReadOnlySpan payload) + { + Lsv2ReadOnlyPolicy.Check(command, payload); + var frame = new byte[HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)payload.Length); + Encoding.ASCII.GetBytes(command, frame.AsSpan(4, 4)); + payload.CopyTo(frame.AsSpan(HeaderSize)); + return frame; + } + + /// NUL-terminated Latin-1 encoding; refuses embedded NULs and non-Latin-1 text. + /// The text. + /// The bytes, NUL included. + public static byte[] Terminated(string value) + { + ArgumentNullException.ThrowIfNull(value); + foreach (var c in value) + if (c == '\0' || c > 0xFF) throw new ArgumentException("Expected NUL-free Latin-1 text."); + var bytes = new byte[value.Length + 1]; + Encoding.Latin1.GetBytes(value, bytes); + return bytes; + } + + /// Requires an exact record length. + /// The record. + /// The expected length. + public static void Length(ReadOnlySpan bytes, int expected) + { + if (bytes.Length != expected) throw new Lsv2DecodeException("Invalid LSV2 record length."); + } + + /// Decodes a single NUL-terminated Latin-1 string that may only be followed by NUL padding. + /// The record. + /// The text. + public static string Text(ReadOnlySpan bytes) + { + if (bytes.Length == 0 || bytes[^1] != 0) throw new Lsv2DecodeException("Unterminated LSV2 string."); + var end = bytes.IndexOf((byte)0); + for (var i = end; i < bytes.Length; i++) + if (bytes[i] != 0) throw new Lsv2DecodeException("Trailing bytes after LSV2 string."); + return Encoding.Latin1.GetString(bytes[..end]); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs new file mode 100644 index 000000000..378fb3031 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs @@ -0,0 +1,231 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +/// Controller identity from R_VR. +public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options); + +/// Program stack from R_RI 24. +public sealed record Lsv2Program(long Line, string Main, string Current); + +/// Tool from R_RI 51; geometry is null on the 8-byte identity-only reply. +public sealed record Lsv2Tool(long Number, int Index, int Axis, double? Length, double? Radius); + +/// One alarm record from R_RI 27/28. +public sealed record Lsv2Alarm(int ErrorClass, int Group, int Number, string Message); + +/// Aligned axis names and positions from R_RI 22 (controller units, no inference). +public sealed record Lsv2Axes(string[] Names, double[] Positions); + +/// An advertised PLC region: base address, capacity in bytes, native element width. +public sealed record Lsv2Region(long Base, long Bytes, int Width); + +/// The R_PR PLC memory layout. +public sealed record Lsv2Layout(IReadOnlyDictionary Regions) +{ + /// The region for an area, or null. + /// The area. + /// The region. + public Lsv2Region? Region(HeidenhainPlcArea area) => Regions.TryGetValue(area, out var r) ? r : null; +} + +/// +/// Pure reference-derived decoders (port of Lsv2Data). Malformed data is never repaired into a Good +/// value: every rule throws . +/// +public static class Lsv2Data +{ + /// Exactly four or five NUL-terminated fields; an optional SPLC 4th field is skipped; empty final options are preserved. + public static Lsv2Versions Versions(ReadOnlySpan bytes) + { + var parts = Strings(bytes, 0); + // Empty final options are meaningful; trimming them would relabel SPLC as options. + if ((parts.Count != 4 && parts.Count != 5) || string.IsNullOrWhiteSpace(parts[0])) throw Bad(); + return new Lsv2Versions(parts[0], parts[1], parts[2], parts[^1]); + } + + /// Exactly two bytes, uint16be. + public static int U16(ReadOnlySpan bytes) + { + Lsv2Codec.Length(bytes, 2); + return BinaryPrimitives.ReadUInt16BigEndian(bytes); + } + + /// Exactly four bytes, uint32be. + public static long U32(ReadOnlySpan bytes) + { + Lsv2Codec.Length(bytes, 4); + return BinaryPrimitives.ReadUInt32BigEndian(bytes); + } + + /// uint32be line, main NUL, current NUL, optional NUL padding only. + public static Lsv2Program Program(ReadOnlySpan bytes) + { + if (bytes.Length < 6) throw Bad(); + var parts = Strings(bytes, 4); + if (parts.Count < 2 || parts.Skip(2).Any(s => s.Length != 0)) throw Bad(); + return new Lsv2Program(BinaryPrimitives.ReadUInt32BigEndian(bytes), parts[0], parts[1]); + } + + /// Three uint32be hundredths of percent: feed, spindle, rapid. + public static double[] Overrides(ReadOnlySpan bytes) + { + Lsv2Codec.Length(bytes, 12); + return + [ + BinaryPrimitives.ReadUInt32BigEndian(bytes) / 100.0, + BinaryPrimitives.ReadUInt32BigEndian(bytes[4..]) / 100.0, + BinaryPrimitives.ReadUInt32BigEndian(bytes[8..]) / 100.0, + ]; + } + + /// 8-byte identity-only or 24-byte reply with two float64le geometry values. + public static Lsv2Tool Tool(ReadOnlySpan bytes) + { + if (bytes.Length != 8 && bytes.Length != 24) throw Bad(); + long number = BinaryPrimitives.ReadUInt32BigEndian(bytes); + int index = BinaryPrimitives.ReadUInt16BigEndian(bytes[4..]); + int axis = BinaryPrimitives.ReadUInt16BigEndian(bytes[6..]); + double? length = null, radius = null; + if (bytes.Length == 24) + { + length = Finite(BinaryPrimitives.ReadDoubleLittleEndian(bytes[8..])); + radius = Finite(BinaryPrimitives.ReadDoubleLittleEndian(bytes[16..])); + } + + return new Lsv2Tool(number, index, axis, length, radius); + } + + /// uint16be class, uint16be group, int32be number, terminated text. + public static Lsv2Alarm Alarm(ReadOnlySpan bytes) + { + if (bytes.Length < 9) throw Bad(); + return new Lsv2Alarm( + BinaryPrimitives.ReadUInt16BigEndian(bytes), + BinaryPrimitives.ReadUInt16BigEndian(bytes[2..]), + BinaryPrimitives.ReadInt32BigEndian(bytes[4..]), + Lsv2Codec.Text(bytes[8..])); + } + + /// Opaque byte, uint8 count, count numeric strings, count unique non-blank names. + public static Lsv2Axes Axes(ReadOnlySpan bytes) + { + if (bytes.Length < 2) throw Bad(); + int count = bytes[1]; + if (count > 64) throw Bad(); + var parts = Strings(bytes, 2); + if (parts.Count != count * 2) throw Bad(); + var names = new string[count]; + var values = new double[count]; + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < count; i++) + { + names[i] = parts[i + count]; + if (string.IsNullOrWhiteSpace(names[i]) || !seen.Add(names[i])) throw Bad(); + if (!double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) throw Bad(); + values[i] = Finite(v); + } + + return new Lsv2Axes(names, values); + } + + /// + /// R_PR: 120 or 124 bytes. Numeric R_PR counts are BYTE capacities (live-verified on three iTNC 530: + /// B9999/W9998/D9996 are the last valid values for a count of 10000); bit and string counts are entries. + /// The next populated distinct area remains a defensive cap for inconsistent layouts. + /// + public static Lsv2Layout Layout(ReadOnlySpan bytes) + { + if (bytes.Length != 120 && bytes.Length != 124) throw Bad(); + var regions = new Dictionary(); + HeidenhainPlcArea[] bits = [HeidenhainPlcArea.Marker, HeidenhainPlcArea.Input, HeidenhainPlcArea.Output, HeidenhainPlcArea.Counter, HeidenhainPlcArea.Timer]; + for (var i = 0; i < bits.Length; i++) Add(regions, bytes, bits[i], i * 8, 1, 1); + Add(regions, bytes, HeidenhainPlcArea.Byte, 40, 1, 1); + Add(regions, bytes, HeidenhainPlcArea.Word, 40, 1, 2); + Add(regions, bytes, HeidenhainPlcArea.Dword, 40, 1, 4); + int stringWidth = bytes[56]; + Add(regions, bytes, HeidenhainPlcArea.String, 48, stringWidth, stringWidth); + Add(regions, bytes, HeidenhainPlcArea.InputWord, 64, 1, 2); + Add(regions, bytes, HeidenhainPlcArea.OutputWord, 72, 1, 2); + CapNumericRegionsAtNextArea(regions); + return new Lsv2Layout(regions); + } + + private static void CapNumericRegionsAtNextArea(Dictionary regions) + { + // Same-base BYTE/WORD/DWORD views intentionally share an area, not a boundary. + var starts = new SortedSet(regions.Values.Where(r => r.Bytes > 0 && r.Width > 0).Select(r => r.Base)); + foreach (var area in new[] { HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, HeidenhainPlcArea.OutputWord }) + { + var region = regions[area]; + var next = starts.GetViewBetween(region.Base + 1, long.MaxValue).Cast().FirstOrDefault(); + if (next is { } n && n - region.Base < region.Bytes) + regions[area] = region with { Bytes = n - region.Base }; + } + } + + private static void Add(Dictionary r, ReadOnlySpan b, HeidenhainPlcArea area, int offset, int unit, int width) + { + long @base = BinaryPrimitives.ReadUInt32BigEndian(b[offset..]); + long size = (long)BinaryPrimitives.ReadUInt32BigEndian(b[(offset + 4)..]) * unit; + if (@base + size > 0x1_0000_0000L) throw Bad(); + r[area] = new Lsv2Region(@base, size, width); + } + + /// + /// R_DP value: uint32be type then 2=int16be, 3=int32be, 5=float64le, 8=terminated text, + /// 11=Boolean byte (00 false; 01/FF true — FF observed on real iTNC 530 I/O), 16=int8, 17=uint8. + /// + public static object DataPath(ReadOnlySpan bytes) + { + if (bytes.Length < 4) throw Bad(); + var type = BinaryPrimitives.ReadUInt32BigEndian(bytes); + switch (type) + { + case 2: + Lsv2Codec.Length(bytes, 6); + return (int)BinaryPrimitives.ReadInt16BigEndian(bytes[4..]); + case 3: + Lsv2Codec.Length(bytes, 8); + return BinaryPrimitives.ReadInt32BigEndian(bytes[4..]); + case 5: + Lsv2Codec.Length(bytes, 12); + return Finite(BinaryPrimitives.ReadDoubleLittleEndian(bytes[4..])); + case 8: + return Lsv2Codec.Text(bytes[4..]); + case 11: + Lsv2Codec.Length(bytes, 5); + if (bytes[4] is not (0 or 1 or 0xFF)) throw Bad(); + return bytes[4] != 0; + case 16: + Lsv2Codec.Length(bytes, 5); + return (int)(sbyte)bytes[4]; + case 17: + Lsv2Codec.Length(bytes, 5); + return (int)bytes[4]; + default: + throw Bad(); + } + } + + private static double Finite(double value) => double.IsFinite(value) ? value : throw Bad(); + + private static List Strings(ReadOnlySpan bytes, int offset) + { + if (offset > bytes.Length || (offset < bytes.Length && bytes[^1] != 0)) throw Bad(); + var result = new List(); + var start = offset; + for (var i = offset; i < bytes.Length; i++) + { + if (bytes[i] != 0) continue; + result.Add(Encoding.Latin1.GetString(bytes[start..i])); + start = i + 1; + } + + return result; + } + + private static Lsv2DecodeException Bad() => new("Malformed LSV2 data record."); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2ReadOnlyPolicy.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2ReadOnlyPolicy.cs new file mode 100644 index 000000000..197632c27 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2ReadOnlyPolicy.cs @@ -0,0 +1,134 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +/// A request the read-only policy refused. Raised BEFORE any byte reaches the socket. +public sealed class Lsv2PolicyException(string message) : InvalidOperationException(message); + +/// +/// The payload-aware, final outbound gate (port of the reference Lsv2ReadOnly). There is deliberately +/// no unrestricted mode, no configuration knob and no public raw-telegram API: every frame +/// builds passes immediately before it is written. +/// +/// +/// The only telegrams this driver can emit: +/// +/// A_LGrole ∈ {INSPECT, DNC, DATA}, NUL; optional NUL-free Latin-1 password, NUL. +/// R_VR, R_PRempty payload. +/// R_RIexactly a uint16be selector ∈ {22–28, 30–34, 51}. +/// R_MBuint32be address + uint8 count, 1 ≤ count ≤ 247, no 32-bit wrap. +/// R_DPfour zero bytes + NUL-free path + NUL. +/// +/// Everything else — every C_* (file / PLC / parameter / buffer-size writes), A_LO, +/// R_MC/R_FL file reads, keyboard or scope operations, PLCDEBUG/MONITOR/FILE roles, any +/// response-type mnemonic — is refused. Do not broaden this list to work around a controller refusal. +/// +public static class Lsv2ReadOnlyPolicy +{ + /// The read-only roles a login may request. + public static readonly IReadOnlySet Roles = new HashSet(StringComparer.Ordinal) { "INSPECT", "DNC", "DATA" }; + + /// The runtime-information selectors a R_RI may carry. + public static readonly IReadOnlySet Selectors = new HashSet { 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51 }; + + /// The permitted request commands. + public static readonly IReadOnlySet Commands = new HashSet(StringComparer.Ordinal) + { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB", "R_DP" }; + + /// Builds a validated login payload. + /// INSPECT, DNC or DATA. + /// The resolved password, or empty. + /// The payload. + public static byte[] LoginPayload(string role, string password) + { + if (!Roles.Contains(role)) throw new Lsv2PolicyException("LSV2 role is not read-only permitted."); + var r = Lsv2Codec.Terminated(role); + var result = string.IsNullOrEmpty(password) ? r : [.. r, .. Lsv2Codec.Terminated(password)]; + Check("A_LG", result); + return result; + } + + /// Builds a validated data-path payload (reference slash normalisation applied). + /// The authored data path. + /// The payload. + public static byte[] DataPathPayload(string path) + { + if (string.IsNullOrWhiteSpace(path)) throw new Lsv2PolicyException("Blank LSV2 data path."); + byte[] text; + try { text = Lsv2Codec.Terminated(HeidenhainAddress.NormalizeDataPath(path)); } + catch (ArgumentException ex) { throw new Lsv2PolicyException(ex.Message); } + var result = new byte[4 + text.Length]; + text.CopyTo(result, 4); + Check("R_DP", result); + return result; + } + + /// Builds a validated runtime-information payload. + /// The selector. + /// The payload. + public static byte[] RuntimePayload(int selector) + { + if (selector is < 0 or > 0xFFFF) throw new Lsv2PolicyException("Invalid LSV2 selector."); + var payload = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(payload, (ushort)selector); + Check("R_RI", payload); + return payload; + } + + /// Builds a validated memory-read payload. + /// Absolute address. + /// Byte count. + /// The payload. + public static byte[] MemoryPayload(long address, int count) + { + if (address is < 0 or > 0xFFFF_FFFFL || count is < 1 or > Lsv2Codec.MaxPayload) + throw new Lsv2PolicyException("Invalid LSV2 memory range."); + var payload = new byte[5]; + BinaryPrimitives.WriteUInt32BigEndian(payload, (uint)address); + payload[4] = (byte)count; + Check("R_MB", payload); + return payload; + } + + /// + /// The final gate: throws unless the command AND its payload are on the + /// allowlist. + /// + /// The four-character command. + /// The payload. + public static void Check(string? command, ReadOnlySpan payload) + { + if (command is null || command.Length != 4 || payload.Length > Lsv2Codec.MaxPayload) + throw new Lsv2PolicyException("Invalid LSV2 request size."); + var allowed = command switch + { + "R_VR" or "R_PR" => payload.Length == 0, + "R_RI" => payload.Length == 2 && Selectors.Contains(BinaryPrimitives.ReadUInt16BigEndian(payload)), + "R_MB" => payload.Length == 5 && payload[4] > 0 && payload[4] <= Lsv2Codec.MaxPayload + && (long)BinaryPrimitives.ReadUInt32BigEndian(payload) + payload[4] <= 0x1_0000_0000L, + "A_LG" => ValidLogin(payload), + "R_DP" => payload.Length > 5 && BinaryPrimitives.ReadUInt32BigEndian(payload) == 0 && ValidText(payload, 4), + _ => false, + }; + if (!allowed) throw new Lsv2PolicyException($"LSV2 read-only policy refused request '{Sanitize(command)}'."); + } + + private static string Sanitize(string command) => + new(command.Select(c => c is >= ' ' and <= '~' ? c : '?').ToArray()); + + private static bool ValidText(ReadOnlySpan p, int offset) + { + if (p.Length <= offset || p[^1] != 0) return false; + for (var i = offset; i < p.Length - 1; i++) if (p[i] == 0) return false; + return true; + } + + private static bool ValidLogin(ReadOnlySpan p) + { + if (p.Length == 0 || p[^1] != 0) return false; + var end = p.IndexOf((byte)0); + var role = Encoding.Latin1.GetString(p[..end]); + return Roles.Contains(role) && (end == p.Length - 1 || ValidText(p, end + 1)); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj new file mode 100644 index 000000000..660a51b98 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + latest + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Heidenhain + + + + + + + + + + + + + + + + + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 429548b45..a3f0fa480 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -24,6 +24,7 @@ using CalculationProbe = Driver.Calculation.CalculationDriverProbe; using SqlProbe = Driver.Sql.SqlDriverProbe; using MqttProbe = Driver.Mqtt.MqttDriverProbe; using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe; +using HeidenhainProbe = Driver.Heidenhain.HeidenhainDriverProbe; /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) @@ -195,6 +196,11 @@ public static class DriverFactoryBootstrap services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + // Factory-built so the probe resolves secret: INSPECT password references through the node's resolver + // (registered on every node by AddZbSecrets; optional so a test graph without one still builds — a + // secret: ref then fails closed with a clear probe message rather than sending the literal). + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => new HeidenhainProbe(sp.GetService()))); return services; } @@ -253,6 +259,9 @@ public static class DriverFactoryBootstrap // tier either — SubscribeAsync returns synchronously after starting the pump on a background // task, so the Tier A 5s Subscribe budget never covers the stream's lifetime. Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory); + // Tier A (default): managed TCP only, no native SDK. Read-only by construction (no IWritable; LSV2 + // outbound allowlist). The resolver backs secret: password references, resolved per login. + Driver.Heidenhain.HeidenhainDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.S7.S7DriverFactoryExtensions.Register(registry, loggerFactory); Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index 159ff0bb0..9c05679ad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -85,6 +85,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj index 4061cda34..270386f3a 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -39,6 +39,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/itnc530-inspect.properties b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/itnc530-inspect.properties new file mode 100644 index 000000000..8288b91b7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/itnc530-inspect.properties @@ -0,0 +1,20 @@ +# Real iTNC530 replies captured 2026-11-03; INSPECT only, no DNC login. +# Provenance: docs/recipes/evidence/heidenhain-live-2026-11-03/direct-inspect-only.json +hermle49.23=S_RI:0004 +hermle49.26=S_RI:0007 +hermle49.24=S_RI:00000000544e433a5c5041525450524f475c30303538383030313330322e690000 +hermle49.25=T_ER:0117 +hermle49.22=S_RI:ff06302e30383339320031302e34333034300032352e373236373300302e30303030003237302e30303030002d39302e3030303000580059005a00430061004100 +hermle49.51=T_ER:0118 +hermle49.27=S_RI:00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300 +hermle49.28=T_ER:0139 +hermle59.23=S_RI:0004 +hermle59.26=S_RI:0007 +hermle59.24=S_RI:00000000544e433a5c7061727470726f675c30303538383030313330322e690000 +hermle59.25=T_ER:0117 +hermle59.22=S_RI:ff06302e30313131390031302e35313739330032352e333130373300302e30303030003138302e30303030002d39302e3030303000580059005a00430061004100 +hermle59.51=S_RI:000000000000000200000000000000000000000000000000 +hermle59.27=S_RI:00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300 +hermle59.28=T_ER:0139 +hermle49.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000000fd00fa00000000000000000000 +hermle59.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000006fd00fa0000071e029000000000 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/plc-count-units.properties b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/plc-count-units.properties new file mode 100644 index 000000000..88feca09d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Fixtures/plc-count-units.properties @@ -0,0 +1,180 @@ +# Real PLC boundary replies, 2026-11-03. DATA + INSPECT only; no writes. +# Evidence: docs/recipes/evidence/heidenhain-plc-units-2026-11-03/ +hermle59.version=69544e43353330003334303439302030342053503130006943333076372e315f3100253030303031313131303030303031313100 +hermle59.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000006fd00fa0000bbd5e9c300000000 +hermle59.B.0=S_DP:0000001000 +hermle59.B.1=S_DP:0000001000 +hermle59.B.9999=S_DP:0000001000 +hermle59.B.10000=T_ER:0118 +hermle59.B.19999=T_ER:0118 +hermle59.B.20000=T_ER:0118 +hermle59.W.0=S_DP:000000020000 +hermle59.W.1=T_ER:0118 +hermle59.W.2=S_DP:000000020000 +hermle59.W.9998=S_DP:000000020000 +hermle59.W.9999=T_ER:0118 +hermle59.W.10000=T_ER:0118 +hermle59.W.19998=T_ER:0118 +hermle59.D.0=S_DP:0000000300000000 +hermle59.D.1=T_ER:0118 +hermle59.D.2=T_ER:0118 +hermle59.D.3=T_ER:0118 +hermle59.D.4=S_DP:000000036aea246c +hermle59.D.9996=S_DP:0000000300000000 +hermle59.D.9997=T_ER:0118 +hermle59.D.10000=T_ER:0118 +hermle59.D.19996=T_ER:0118 +hermle59.IW.0=S_DP:000000020000 +hermle59.IW.1=T_ER:0118 +hermle59.IW.2=S_DP:000000020000 +hermle59.IW.998=S_DP:000000020000 +hermle59.IW.999=T_ER:0118 +hermle59.IW.1000=T_ER:0118 +hermle59.IW.1998=T_ER:0118 +hermle59.OW.0=S_DP:000000020000 +hermle59.OW.1=T_ER:0118 +hermle59.OW.2=S_DP:000000020000 +hermle59.OW.998=S_DP:000000020000 +hermle59.OW.999=T_ER:0118 +hermle59.OW.1000=T_ER:0118 +hermle59.OW.1998=T_ER:0118 +hermle59.M.0=S_DP:0000000b00 +hermle59.M.9999=S_DP:0000000b00 +hermle59.M.10000=T_ER:0118 +hermle59.I.0=S_DP:0000000b00 +hermle59.I.999=S_DP:0000000bff +hermle59.I.1000=T_ER:0118 +hermle59.O.0=S_DP:0000000b00 +hermle59.O.999=S_DP:0000000bff +hermle59.O.1000=T_ER:0118 +hermle59.K.0=T_ER:0118 +hermle59.T.0=S_DP:0000000b00 +hermle59.T.999=S_DP:0000000b00 +hermle59.T.1000=T_ER:0118 +hermle59.S.0=S_DP:000000083020202035302020313030203135302020202020201a453136302020202020203000 +hermle59.S.99=S_DP:0000000800 +hermle59.S.100=T_ER:0118 +hermle59.C.0=S_DP:0000000b00 +hermle59.C.1=S_DP:0000000b00 +hermle59.C.143=S_DP:0000000b00 +hermle59.C.144=T_ER:0118 +hermle70.version=69544e43353330003630363432302030332053503400694332325f5632302e315f3253503200253030303031313131303030303031313100 +hermle70.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff1000000000000000000000000efd00fa0000848c272300000000 +hermle70.B.0=S_DP:0000001002 +hermle70.B.1=S_DP:0000001000 +hermle70.B.9999=S_DP:0000001000 +hermle70.B.10000=T_ER:0118 +hermle70.B.19999=T_ER:0118 +hermle70.B.20000=T_ER:0118 +hermle70.W.0=S_DP:000000020002 +hermle70.W.1=T_ER:0118 +hermle70.W.2=S_DP:000000020000 +hermle70.W.9998=S_DP:000000020000 +hermle70.W.9999=T_ER:0118 +hermle70.W.10000=T_ER:0118 +hermle70.W.19998=T_ER:0118 +hermle70.D.0=S_DP:0000000300000002 +hermle70.D.1=T_ER:0118 +hermle70.D.2=T_ER:0118 +hermle70.D.3=T_ER:0118 +hermle70.D.4=S_DP:000000036aea678d +hermle70.D.9996=S_DP:0000000300000000 +hermle70.D.9997=T_ER:0118 +hermle70.D.10000=T_ER:0118 +hermle70.D.19996=T_ER:0118 +hermle70.IW.0=S_DP:000000020245 +hermle70.IW.1=T_ER:0118 +hermle70.IW.2=S_DP:0000000202e0 +hermle70.IW.998=S_DP:000000020000 +hermle70.IW.999=T_ER:0118 +hermle70.IW.1000=T_ER:0118 +hermle70.IW.1998=T_ER:0118 +hermle70.OW.0=S_DP:000000020000 +hermle70.OW.1=T_ER:0118 +hermle70.OW.2=S_DP:000000020000 +hermle70.OW.998=S_DP:000000020000 +hermle70.OW.999=T_ER:0118 +hermle70.OW.1000=T_ER:0118 +hermle70.OW.1998=T_ER:0118 +hermle70.M.0=S_DP:0000000b00 +hermle70.M.9999=S_DP:0000000b00 +hermle70.M.10000=T_ER:0118 +hermle70.I.0=S_DP:0000000b01 +hermle70.I.999=S_DP:0000000b00 +hermle70.I.1000=T_ER:0118 +hermle70.O.0=S_DP:0000000b00 +hermle70.O.999=S_DP:0000000b00 +hermle70.O.1000=T_ER:0118 +hermle70.K.0=S_DP:0000000300000000 +hermle70.K.143=S_DP:000000030000008f +hermle70.K.144=S_DP:0000000300000090 +hermle70.T.0=S_DP:0000000b00 +hermle70.T.999=S_DP:0000000b00 +hermle70.T.1000=T_ER:0118 +hermle70.S.0=S_DP:000000084f4e2f4f464600 +hermle70.S.99=S_DP:0000000800 +hermle70.S.100=T_ER:0118 +hermle70.C.0=S_DP:0000000b00 +hermle70.C.1=S_DP:0000000b00 +hermle70.C.143=S_DP:0000000b00 +hermle70.C.144=T_ER:0118 +hermle29.version=69544e43353330003630363432302030332053503400694332325f5632302e315f3253503200253030303031313131303030303031313100 +hermle29.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff1000000000000000000000000efd00fa0000a57d03a900000000 +hermle29.B.0=S_DP:0000001002 +hermle29.B.1=S_DP:0000001000 +hermle29.B.9999=S_DP:0000001000 +hermle29.B.10000=T_ER:0118 +hermle29.B.19999=T_ER:0118 +hermle29.B.20000=T_ER:0118 +hermle29.W.0=S_DP:000000020002 +hermle29.W.1=T_ER:0118 +hermle29.W.2=S_DP:000000020000 +hermle29.W.9998=S_DP:000000020000 +hermle29.W.9999=T_ER:0118 +hermle29.W.10000=T_ER:0118 +hermle29.W.19998=T_ER:0118 +hermle29.D.0=S_DP:0000000300000002 +hermle29.D.1=T_ER:0118 +hermle29.D.2=T_ER:0118 +hermle29.D.3=T_ER:0118 +hermle29.D.4=S_DP:000000036ae91e99 +hermle29.D.9996=S_DP:0000000300000000 +hermle29.D.9997=T_ER:0118 +hermle29.D.10000=T_ER:0118 +hermle29.D.19996=T_ER:0118 +hermle29.IW.0=S_DP:00000002023f +hermle29.IW.1=T_ER:0118 +hermle29.IW.2=S_DP:000000020307 +hermle29.IW.998=S_DP:000000020000 +hermle29.IW.999=T_ER:0118 +hermle29.IW.1000=T_ER:0118 +hermle29.IW.1998=T_ER:0118 +hermle29.OW.0=S_DP:000000020000 +hermle29.OW.1=T_ER:0118 +hermle29.OW.2=S_DP:000000020000 +hermle29.OW.998=S_DP:000000020000 +hermle29.OW.999=T_ER:0118 +hermle29.OW.1000=T_ER:0118 +hermle29.OW.1998=T_ER:0118 +hermle29.M.0=S_DP:0000000b00 +hermle29.M.9999=S_DP:0000000b00 +hermle29.M.10000=T_ER:0118 +hermle29.I.0=S_DP:0000000b01 +hermle29.I.999=S_DP:0000000b00 +hermle29.I.1000=T_ER:0118 +hermle29.O.0=S_DP:0000000b00 +hermle29.O.999=S_DP:0000000b00 +hermle29.O.1000=T_ER:0118 +hermle29.K.0=S_DP:0000000300000000 +hermle29.K.143=S_DP:000000030000008f +hermle29.K.144=S_DP:0000000300000090 +hermle29.T.0=S_DP:0000000b00 +hermle29.T.999=S_DP:0000000b00 +hermle29.T.1000=T_ER:0118 +hermle29.S.0=S_DP:000000084f4e2f4f464600 +hermle29.S.99=S_DP:0000000800 +hermle29.S.100=T_ER:0118 +hermle29.C.0=S_DP:0000000b00 +hermle29.C.1=S_DP:0000000b00 +hermle29.C.143=S_DP:0000000b00 +hermle29.C.144=T_ER:0118 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainConfigTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainConfigTests.cs new file mode 100644 index 000000000..a5702b726 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainConfigTests.cs @@ -0,0 +1,163 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// TagConfig grammar, catalog and options validation (reference HeidenhainBindingsTest / HeidenhainConfigTest). +[Trait("Category", "Unit")] +public sealed class HeidenhainConfigTests +{ + private static bool Accepts(string json) => HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out _, out _); + + [Fact] + public void Parses_both_binding_families_and_preserves_colons_in_data_paths() + { + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"PLC:MARKER:32","dataType":"Boolean"}""", out var a, out _).ShouldBeTrue(); + a!.Address.Area.ShouldBe(HeidenhainPlcArea.Marker); + a.Address.Index.ShouldBe(32); + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"DATA:/TABLE/TNC:TOOL/DOC","dataType":"String"}""", out var b, out _).ShouldBeTrue(); + b!.Address.DataPath.ShouldBe("/TABLE/TNC:TOOL/DOC"); + b.DataType.ShouldBe(DriverDataType.String); + } + + [Theory] + [InlineData("""{"address":"PLC:WORD:0","dataType":"Boolean"}""")] // area/type mismatch + [InlineData("""{"address":"PLC:MARKER:0","dataType":"Int32"}""")] + [InlineData("""{"address":"PLC:BYTE:-1","dataType":"Int16"}""")] + [InlineData("""{"address":"PLC:DWORD:0","dataType":"Int16"}""")] // narrows native range + [InlineData("""{"address":"PLC:INPUT_WORD:0","dataType":"Int16"}""")] // uint16 into int16 + [InlineData("""{"address":"PLC:STRING:0","dataType":"Int32"}""")] + [InlineData("""{"address":"PLC:WORD:99999999999","dataType":"Int64"}""")] // > uint32 + [InlineData("""{"address":"PLC:WORD:4294967296","dataType":"Int64"}""")] + [InlineData("""{"address":"PLC:WORDS:0","dataType":"Int64"}""")] + [InlineData("""{"address":"PLC:WORD:0"}""")] // custom needs a dataType + [InlineData("""{"address":"PLC:WORD:0","dataType":"Float32"}""")] + [InlineData("""{"address":"PLC:WORD:0","dataType":"UInt16"}""")] + [InlineData("""{"address":"PLC:WORD:0","dataType":"8"}""")] // numerically-serialized enum + [InlineData("""{"address":"PLC:WORD:0","dataType":8}""")] + [InlineData("""{"address":"DATA:/a\u0000b","dataType":"String"}""")] + [InlineData("""{"address":"DATA:/€","dataType":"String"}""")] + [InlineData("""{"address":"DATA:","dataType":"String"}""")] + [InlineData("""{"address":"Status/Nope"}""")] + [InlineData("""{"address":"status/executionstate"}""")] + [InlineData("""{"address":"Status/ExecutionState","dataType":"String"}""")] // catalog type mismatch + [InlineData("""{"address":"Axes/Names"}""")] // array leaf without isArray + [InlineData("""{"address":"Status/ExecutionState","isArray":true}""")] // scalar marked array + [InlineData("""{"address":"Status/ExecutionState","writable":true}""")] // read-only driver + [InlineData("""{"address":42}""")] + [InlineData("""[]""")] + [InlineData("""not json""")] + [InlineData("")] + public void Rejects_unsafe_mismatched_or_malformed_blobs(string json) + { + Accepts(json).ShouldBeFalse(json); + HeidenhainTagDefinitionFactory.Inspect(json).ShouldNotBeEmpty(); + } + + [Fact] + public void Inspect_is_never_stricter_than_the_runtime_parse() + { + foreach (var e in HeidenhainCatalog.Entries) + { + var json = TestTags.Config(e.Address); + Accepts(json).ShouldBeTrue(json); + HeidenhainTagDefinitionFactory.Inspect(json).ShouldBeEmpty(); + } + + Accepts("""{"address":"PLC:INPUT_WORD:0","dataType":"Int32","isHistorized":true}""").ShouldBeTrue(); + Accepts("""{"address":"Status/ExecutionState","dataType":"int32"}""").ShouldBeTrue(); + } + + [Fact] + public void Catalog_is_general_purpose_read_only_and_without_oee_mapping() + { + HeidenhainCatalog.Entries.ShouldContain(e => e.Address == "Axes/Positions" && e.IsArray && e.DataType == DriverDataType.Float64); + HeidenhainCatalog.Entries.ShouldContain(e => e.Address == "Status/ExecutionState" && e.DataType == DriverDataType.Int32); + HeidenhainCatalog.Entries.ShouldNotContain(e => e.Address.Contains("RunState") || e.Address.Contains("GoodCount") || e.Address.Contains("IdealRate")); + HeidenhainCatalog.Entries.Select(e => e.Address).ShouldBeUnique(); + HeidenhainCatalog.Entries.Where(e => e.IsCapability).ShouldAllBe(e => e.DataType == DriverDataType.String); + HeidenhainCatalog.Entries.Count(e => e.Address.StartsWith("Timers/")).ShouldBe(5); + } + + [Fact] + public void Options_have_reference_defaults() + { + var o = HeidenhainOptionsParser.Parse("{}"); + o.TimeoutMs.ShouldBe(5000); + o.Polling.StatusMs.ShouldBe(1000); + o.Polling.CustomMs.ShouldBe(1000); + o.Polling.SlowMs.ShouldBe(10000); + o.Polling.MaxAlarms.ShouldBe(64); + o.Polling.TimersEnabled.ShouldBeFalse(); + o.Access.TelemetryEnabled.ShouldBeTrue(); + o.Access.DataEnabled.ShouldBeFalse(); + new HeidenhainDeviceOptions().Port.ShouldBe(19000); + HeidenhainOptionsParser.Validate(o).ShouldBeEmpty(); + } + + [Theory] + [InlineData("""{"TimeoutMs":99}""")] + [InlineData("""{"TimeoutMs":60001}""")] + [InlineData("""{"Polling":{"StatusMs":50}}""")] + [InlineData("""{"Polling":{"SlowMs":3600001}}""")] + [InlineData("""{"Polling":{"MaxAlarms":0}}""")] + [InlineData("""{"Polling":{"MaxAlarms":257}}""")] + [InlineData("""{"Reconnect":{"InitialDelayMs":5000,"MaxDelayMs":1000}}""")] + [InlineData("""{"Access":{"InspectPassword":"hunter2"}}""")] + [InlineData("""{"Access":{"DncPassword":"secret:"}}""")] + [InlineData("""{"Devices":[{"Host":"h","DataPassword":"plain"}]}""")] + public void Invalid_options_are_rejected_without_echoing_credentials(string json) + { + var errors = HeidenhainOptionsParser.Validate(HeidenhainOptionsParser.Parse(json)); + errors.ShouldNotBeEmpty(); + string.Join(" ", errors).ShouldNotContain("hunter2"); + string.Join(" ", errors).ShouldNotContain("plain'"); + Should.Throw(() => HeidenhainDriver.ParseOptions(json)); + } + + [Theory] + [InlineData("secret:lsv2-inspect")] + [InlineData("env:LSV2_DNC_PASSWORD")] + [InlineData("")] + [InlineData(null)] + public void Credential_references_are_accepted(string? value) => + HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeTrue(); + + [Theory] + [InlineData("")] + [InlineData("http://host")] + [InlineData("host name")] + [InlineData("a\\b")] + public void Device_hosts_are_validated(string host) => + HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = host }).ShouldNotBeEmpty(); + + [Fact] + public void Device_port_is_validated() => + HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = "10.0.0.1", Port = 0 }).ShouldNotBeEmpty(); + + [Fact] + public async Task Credentials_resolve_from_secret_store_and_environment_and_fail_closed() + { + var resolver = new FakeResolver(new() { ["lsv2"] = "s3cret" }); + var env = new Dictionary { ["LSV2_PW"] = "envpw" }; + var c = new HeidenhainCredentials(resolver, n => env.GetValueOrDefault(n)); + var ct = TestContext.Current.CancellationToken; + (await c.ResolveReferenceAsync("secret:lsv2", "INSPECT", ct)).ShouldBe("s3cret"); + (await c.ResolveReferenceAsync("env:LSV2_PW", "DNC", ct)).ShouldBe("envpw"); + (await c.ResolveReferenceAsync(null, "DNC", ct)).ShouldBe(""); + await Should.ThrowAsync(() => c.ResolveReferenceAsync("secret:missing", "DNC", ct).AsTask()); + await Should.ThrowAsync(() => c.ResolveReferenceAsync("env:UNSET", "DNC", ct).AsTask()); + await Should.ThrowAsync(() => c.ResolveReferenceAsync("literal", "DNC", ct).AsTask()); + await Should.ThrowAsync(() => new HeidenhainCredentials(null).ResolveReferenceAsync("secret:lsv2", "DNC", ct).AsTask()); + // device override wins over the driver-level reference + var o = new HeidenhainDriverOptions { Access = new() { InspectPassword = "secret:missing" } }; + (await c.ResolveAsync(o, new HeidenhainDeviceOptions { Host = "h", InspectPassword = "secret:lsv2" }, "INSPECT", ct)).ShouldBe("s3cret"); + } + + private sealed class FakeResolver(Dictionary secrets) : ZB.MOM.WW.Secrets.Abstractions.ISecretResolver + { + public Task GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct) => + Task.FromResult(secrets.TryGetValue(name.Value, out var v) ? v : null); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs new file mode 100644 index 000000000..91ba62b6f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs @@ -0,0 +1,306 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// +/// End-to-end driver behaviour against the in-process LSV2 simulator: cached reads, subscription fan-out, +/// all-node invalidation on comms loss, recovery re-publish of IDENTICAL values, bounded reconnect, +/// re-init teardown, browse-without-connect and the read-only boundary. +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainDriverTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static SimulatedControl Itnc() + { + var c = new SimulatedControl(); + c.Runtime[23] = () => SimulatedControl.U16(4); + c.Runtime[26] = () => SimulatedControl.U16(7); + c.Runtime[25] = () => SimReply.Ok("S_RI", [0, 0, 0x27, 0x10, 0, 0, 0x1F, 0x40, 0, 0, 0, 0]); + c.Runtime[27] = () => SimReply.Refuse(57); + c.Memory = (_, n) => SimReply.Ok("S_MB", n == 4 ? [42, 0, 0, 0] : new byte[n]); + return c; + } + + private static HeidenhainDriverOptions Options(int port, params RawTagEntry[] tags) => new() + { + Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }], + RawTags = tags, + TimeoutMs = 500, + Polling = new HeidenhainPollingOptions { StatusMs = 100, CustomMs = 100, SlowMs = 300 }, + Reconnect = new HeidenhainReconnectOptions { InitialDelayMs = 100, MaxDelayMs = 400 }, + }; + + private static RawTagEntry[] StandardTags() => + [ + TestTags.Raw("Status/ExecutionState"), + TestTags.Raw("Overrides/Feed"), + TestTags.Raw("Controller/Model"), + TestTags.Raw("Alarms/Count"), + TestTags.Raw("PLC:DWORD:0", "Int64"), + TestTags.Raw("Capabilities/Identity"), + ]; + + private sealed class Recorder + { + public ConcurrentQueue<(string Ref, DataValueSnapshot Snapshot)> Events { get; } = new(); + public void Attach(HeidenhainDriver d) => d.OnDataChange += (_, e) => Events.Enqueue((e.FullReference, e.Snapshot)); + public IReadOnlyList For(string rawPath) => Events.Where(e => e.Ref == rawPath).Select(e => e.Snapshot).ToList(); + public DataValueSnapshot? Last(string rawPath) => For(rawPath).LastOrDefault(); + } + + private static async Task WaitUntil(Func condition, int timeoutMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); + await Task.Delay(20, Ct); + } + } + + private static HeidenhainDriver Create(HeidenhainDriverOptions o) => + new(o, "hh-1", null, new HeidenhainCredentials(null), null, null); + + [Fact] + public async Task Initialize_and_discovery_never_contact_the_control() + { + var control = Itnc(); + await using var sim = Lsv2Simulator.WithControl(control); + await using var d = Create(Options(sim.Port, StandardTags())); + await d.InitializeAsync("{}", Ct); + var builder = new CapturingBuilder(); + await d.DiscoverAsync(builder, Ct); + await Task.Delay(200, Ct); + sim.Connections.ShouldBe(0); + builder.Variables.Count.ShouldBe(HeidenhainCatalog.Entries.Count); + builder.Variables.ShouldContain(v => v.FullName == "Axes/Positions" && v.IsArray && v.DriverDataType == DriverDataType.Float64); + builder.Variables.ShouldAllBe(v => v.SecurityClass == SecurityClassification.ViewOnly); + d.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Subscribe_pushes_initial_state_then_cached_good_values_and_reads_never_touch_the_wire() + { + var control = Itnc(); + await using var sim = Lsv2Simulator.WithControl(control); + var tags = StandardTags(); + await using var d = Create(Options(sim.Port, tags)); + var rec = new Recorder(); + rec.Attach(d); + await d.InitializeAsync("{}", Ct); + await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct); + rec.For(tags[0].RawPath)[0].StatusCode.ShouldBe(HeidenhainStatus.BadWaitingForInitialData); + await WaitUntil(() => rec.Last(tags[3].RawPath)?.StatusCode == HeidenhainStatus.Good + && rec.Last(tags[4].RawPath)?.StatusCode == HeidenhainStatus.Good); + rec.Last(tags[0].RawPath)!.Value.ShouldBe(4); + rec.Last(tags[1].RawPath)!.Value.ShouldBe(100.0); + rec.Last(tags[2].RawPath)!.Value.ShouldBe("iTNC 530"); + rec.Last(tags[3].RawPath)!.Value.ShouldBe(0); + rec.Last(tags[4].RawPath)!.Value.ShouldBe(42L); + rec.Last(tags[5].RawPath)!.Value.ShouldBe("Supported"); + + var before = sim.Requests.Count; + var read = await d.ReadAsync([tags[0].RawPath, "Plant/H/dev1/nope"], Ct); + read[0].Value.ShouldBe(4); + read[1].StatusCode.ShouldBe(HeidenhainStatus.BadNodeIdUnknown); + sim.Requests.Count.ShouldBeLessThanOrEqualTo(before + 3); // the background loop may tick; the read itself is cache-only + d.GetHealth().State.ShouldBe(DriverState.Healthy); + d.GetHostStatuses().Single().State.ShouldBe(HostState.Running); + control.Violations.ShouldBeEmpty(); + sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB" }); + } + + [Fact] + public async Task Unchanged_values_are_not_republished() + { + var control = Itnc(); + await using var sim = Lsv2Simulator.WithControl(control); + var tag = TestTags.Raw("Status/ExecutionState"); + await using var d = Create(Options(sim.Port, tag)); + var rec = new Recorder(); + rec.Attach(d); + await d.InitializeAsync("{}", Ct); + await d.SubscribeAsync([tag.RawPath], TimeSpan.FromMilliseconds(100), Ct); + await WaitUntil(() => sim.Requests.Count(r => r.Command == "R_RI") >= 5); + rec.For(tag.RawPath).Count.ShouldBe(2); // initial BadWaitingForInitialData + one Good + } + + [Fact] + public async Task Comms_loss_invalidates_every_remote_value_and_recovery_republishes_identical_values() + { + var control = Itnc(); + await using var sim = Lsv2Simulator.WithControl(control); + var tags = StandardTags(); + await using var d = Create(Options(sim.Port, tags)); + var rec = new Recorder(); + rec.Attach(d); + var hostEvents = new ConcurrentQueue(); + d.OnHostStatusChanged += (_, e) => hostEvents.Enqueue(e); + await d.InitializeAsync("{}", Ct); + await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct); + await WaitUntil(() => tags.All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.Good)); + var lastGood = d.GetHealth().LastSuccessfulRead; + + sim.GoOffline(); + // EVERY remote leaf — fast status, slow identity/alarms and custom PLC — goes Bad at once. + await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication)); + rec.Last(tags[5].RawPath)!.Value.ShouldBe("CommunicationLost"); + rec.Last(tags[5].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.Good); + (await d.ReadAsync([tags[2].RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication); + await WaitUntil(() => d.GetHealth().State == DriverState.Degraded); + d.GetHealth().LastError.ShouldNotBeNullOrEmpty(); + d.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped); + hostEvents.ShouldContain(e => e.NewState == HostState.Stopped); + var frozen = d.GetHealth().LastSuccessfulRead; + await Task.Delay(500, Ct); + d.GetHealth().LastSuccessfulRead.ShouldBe(frozen); // frozen while down — the comms-loss watchdog's evidence + (frozen <= lastGood!.Value.AddSeconds(1)).ShouldBeTrue(); + + sim.GoOnline(); + // The control comes back holding the SAME readings: the (value, status) dedup must still re-publish. + await WaitUntil(() => tags.All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.Good), 8000); + foreach (var t in tags.Take(5)) + { + var seq = rec.For(t.RawPath).Select(s => s.StatusCode).ToList(); + seq.ShouldContain(HeidenhainStatus.BadNoCommunication); + seq[^1].ShouldBe(HeidenhainStatus.Good); + } + + rec.Last(tags[0].RawPath)!.Value.ShouldBe(4); + rec.Last(tags[4].RawPath)!.Value.ShouldBe(42L); + d.GetHealth().State.ShouldBe(DriverState.Healthy); + hostEvents.Last().NewState.ShouldBe(HostState.Running); + control.Violations.ShouldBeEmpty(); + } + + [Fact] + public async Task Reconnect_is_bounded_exponential_backoff() + { + int port; + await using (var probe = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = probe.Port; + var tag = TestTags.Raw("Status/ExecutionState"); + var o = Options(port, tag); + var attempts = new ConcurrentQueue(); + var d = new HeidenhainDriver(o, "hh-1", null, new HeidenhainCredentials(null), + (h, p, t) => { attempts.Enqueue(DateTime.UtcNow); return new Lsv2.Lsv2Client(h, p, t); }, null); + await using (d) + { + await d.InitializeAsync("{}", Ct); + await d.SubscribeAsync([tag.RawPath], TimeSpan.FromSeconds(1), Ct); + await Task.Delay(2000, Ct); + var a = attempts.ToArray(); + a.Length.ShouldBeInRange(3, 8); // 100, 200, 400, 400, … ms — never a hot loop + for (var i = 2; i < a.Length; i++) + (a[i] - a[i - 1]).TotalMilliseconds.ShouldBeGreaterThan(150); + d.GetHealth().State.ShouldBe(DriverState.Degraded); + (await d.ReadAsync([tag.RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication); + } + } + + [Fact] + public async Task Refusals_are_per_tag_quality_and_do_not_degrade_driver_health() + { + var control = Itnc(); + control.Runtime[25] = () => SimReply.Refuse(23); + control.Login = (role, _) => role == "DNC" ? SimReply.Refuse(23) : SimReply.Ok("T_OK", []); + await using var sim = Lsv2Simulator.WithControl(control); + var tags = new[] { TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Overrides/Feed"), TestTags.Raw("PLC:WORD:1", "Int32") }; + control.Memory = null; // R_MB denied, as on every tested iTNC 530 + await using var d = Create(Options(sim.Port, tags)); + var rec = new Recorder(); + rec.Attach(d); + await d.InitializeAsync("{}", Ct); + await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct); + await WaitUntil(() => rec.Last(tags[2].RawPath)?.StatusCode == HeidenhainStatus.BadUserAccessDenied); + rec.Last(tags[0].RawPath)!.Value.ShouldBe(4); + rec.Last(tags[1].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.BadUserAccessDenied); + d.GetHealth().State.ShouldBe(DriverState.Healthy); + d.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); + d.GetDiagnostics().Values.ShouldContain("Partial"); + } + + [Fact] + public async Task Reinitialize_tears_down_the_previous_session_before_reconnecting() + { + var control = Itnc(); + await using var sim = Lsv2Simulator.WithControl(control); + var tag = TestTags.Raw("Status/ExecutionState"); + var json = JsonSerializer.Serialize(Options(sim.Port, tag)); + await using var d = HeidenhainDriverFactoryExtensions.CreateInstance("hh-1", json); + for (var i = 0; i < 3; i++) + { + await d.InitializeAsync(json, Ct); + await d.SubscribeAsync([tag.RawPath], TimeSpan.FromMilliseconds(100), Ct); + await WaitUntil(() => sim.Connections == i + 1); + } + + await d.ShutdownAsync(Ct); + await Task.Delay(200, Ct); + var after = sim.Requests.Count; + await Task.Delay(300, Ct); + sim.Requests.Count.ShouldBe(after); // nothing polls after shutdown + } + + [Fact] + public async Task Bad_tags_and_unknown_devices_are_skipped_and_reported() + { + var o = Options(19000, + TestTags.Raw("Status/ExecutionState"), + new RawTagEntry("Plant/H/dev1/bad", """{"address":"PLC:WORD:0","dataType":"Boolean"}""", false, "dev1"), + TestTags.Raw("Status/ProgramState", device: "other")); + o = new HeidenhainDriverOptions + { + Devices = [o.Devices[0], new HeidenhainDeviceOptions { Host = "127.0.0.2", DeviceName = "dev2" }], + RawTags = o.RawTags, + }; + await using var d = Create(o); + await d.InitializeAsync("{}", Ct); + var report = d.GetSkippedTags()!; + report.AuthoredCount.ShouldBe(3); + report.SkippedCount.ShouldBe(2); + (await d.ReadAsync(["Plant/H/dev1/bad"], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNodeIdUnknown); + d.ResolveHost("Plant/H/dev1/Status/ExecutionState").ShouldBe("127.0.0.1:19000"); + } + + [Fact] + public async Task Factory_registers_under_the_canonical_type_and_rejects_literal_passwords() + { + var registry = new DriverFactoryRegistry(); + HeidenhainDriverFactoryExtensions.Register(registry); + var json = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"d"}],"Access":{"InspectPassword":"secret:lsv2/inspect"}}"""; + var driver = registry.TryGet(DriverTypeNames.Heidenhain)!("hh", json); + driver.ShouldBeOfType(); + driver.DriverType.ShouldBe(DriverTypeNames.Heidenhain); + Should.Throw(() => + HeidenhainDriverFactoryExtensions.CreateInstance("hh", """{"Access":{"InspectPassword":"plain"}}""")); + await Task.CompletedTask; + } + + private sealed class CapturingBuilder : IAddressSpaceBuilder + { + public List Variables { get; } = []; + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add(attributeInfo); + return new Handle(attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullReference) : IVariableHandle + { + public string FullReference => fullReference; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs new file mode 100644 index 000000000..21897b8df --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs @@ -0,0 +1,88 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// +/// Real-controller commissioning gate — env-gated, skipped unless HEIDENHAIN_LSV2_HOST is set. READ-ONLY: +/// it issues only the allowlisted INSPECT login, version, layout and runtime-info reads that the driver itself +/// uses (every frame goes through ). Run with +/// dotnet test --filter Category=LiveIntegration; record the output as .NET commissioning evidence. +/// +/// +/// Env: HEIDENHAIN_LSV2_HOST (required), HEIDENHAIN_LSV2_PORT (default 19000), +/// HEIDENHAIN_LSV2_INSPECT_PASSWORD (optional; never logged). +/// +[Trait("Category", "LiveIntegration")] +public sealed class HeidenhainLiveIntegrationTests +{ + private static string? Host => Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_HOST"); + + private static int Port => int.TryParse(Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_PORT"), out var p) ? p : 19000; + + [Fact] + public async Task Real_control_inspect_identity_layout_and_status() + { + Assert.SkipWhen(string.IsNullOrWhiteSpace(Host), "HEIDENHAIN_LSV2_HOST not set — real-controller gate skipped."); + var ct = TestContext.Current.CancellationToken; + await using var client = new Lsv2Client(Host!, Port, TimeSpan.FromSeconds(5)); + await client.OpenAsync(ct); + await client.LoginAsync("INSPECT", Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_INSPECT_PASSWORD") ?? "", ct); + var versions = Lsv2Data.Versions(await client.ReadVersionAsync(ct)); + TestContext.Current.SendDiagnosticMessage($"model={versions.Model} nc={versions.Nc} plc={versions.Plc}"); + versions.Model.ShouldNotBeNullOrWhiteSpace(); + foreach (var selector in new[] { 23, 26, 24, 25, 22, 51 }) + { + try + { + var bytes = await client.ReadRuntimeAsync(selector, ct); + TestContext.Current.SendDiagnosticMessage($"R_RI {selector}: {bytes.Length} bytes"); + } + catch (Lsv2RefusalException ex) + { + TestContext.Current.SendDiagnosticMessage($"R_RI {selector}: refused code {ex.Code} -> {HeidenhainStatus.State(HeidenhainStatus.FromRefusal(ex.Code))}"); + } + } + + try + { + var layout = Lsv2Data.Layout(await client.ReadParametersAsync(ct)); + TestContext.Current.SendDiagnosticMessage($"R_PR WORD region base={layout.Region(HeidenhainPlcArea.Word)!.Base} bytes={layout.Region(HeidenhainPlcArea.Word)!.Bytes}"); + } + catch (Lsv2RefusalException ex) + { + TestContext.Current.SendDiagnosticMessage($"R_PR refused code {ex.Code}"); + } + + client.IsClosed.ShouldBeFalse(); + } + + [Fact] + public async Task Real_control_driver_end_to_end_status_poll() + { + Assert.SkipWhen(string.IsNullOrWhiteSpace(Host), "HEIDENHAIN_LSV2_HOST not set — real-controller gate skipped."); + var ct = TestContext.Current.CancellationToken; + var tags = new[] { TestTags.Raw("Controller/Model"), TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Capabilities/Execution") }; + var options = new HeidenhainDriverOptions + { + Devices = [new HeidenhainDeviceOptions { Host = Host!, Port = Port, DeviceName = "dev1" }], + RawTags = tags, + Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_INSPECT_PASSWORD") is { Length: > 0 } ? "env:HEIDENHAIN_LSV2_INSPECT_PASSWORD" : null }, + }; + await using var driver = new HeidenhainDriver(options, "live"); + await driver.InitializeAsync("{}", ct); + await driver.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromSeconds(1), ct); + var deadline = DateTime.UtcNow.AddSeconds(30); + IReadOnlyList values; + do + { + await Task.Delay(500, ct); + values = await driver.ReadAsync(tags.Select(t => t.RawPath).ToList(), ct); + } while (values[0].StatusCode != HeidenhainStatus.Good && DateTime.UtcNow < deadline); + + for (var i = 0; i < tags.Length; i++) + TestContext.Current.SendDiagnosticMessage($"{tags[i].RawPath} = {values[i].Value} (0x{values[i].StatusCode:X8})"); + values[0].StatusCode.ShouldBe(HeidenhainStatus.Good); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPlcUnitsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPlcUnitsTests.cs new file mode 100644 index 000000000..ec10d2a2f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPlcUnitsTests.cs @@ -0,0 +1,76 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// +/// Replays the actual DATA boundary replies captured on three iTNC 530 controls (reference +/// HeidenhainPlcUnitsTest): R_PR numeric counts are BYTE capacities, binding indexes are ELEMENT +/// indexes, and the DATA Boolean FF encoding is accepted. +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainPlcUnitsTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("hermle59")] + [InlineData("hermle70")] + [InlineData("hermle29")] + public async Task Real_boundaries_match_region_byte_counts_and_element_widths(string machine) + { + var f = Fixtures.Load("plc-count-units.properties"); + var control = new SimulatedControl + { + Version = Convert.FromHexString(f[machine + ".version"]), + Layout = Convert.FromHexString(f[machine + ".layout"]), + }; + foreach (var (key, value) in f) + { + if (!key.StartsWith(machine + ".", StringComparison.Ordinal) || key.EndsWith(".version", StringComparison.Ordinal) || key.EndsWith(".layout", StringComparison.Ordinal)) continue; + var parts = key.Split('.'); + control.DataPaths[$"/PLC/memory/{parts[1]}/{parts[2]}"] = SimReply.FromFixture(value); + } + + await using var sim = Lsv2Simulator.WithControl(control); + await using var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + await client.OpenAsync(Ct); + await client.LoginAsync("INSPECT", "", Ct); + await client.LoginAsync("DATA", "", Ct); + var layout = Lsv2Data.Layout(await client.ReadParametersAsync(Ct)); + + foreach (var (token, area) in new[] { ("B", HeidenhainPlcArea.Byte), ("W", HeidenhainPlcArea.Word), ("D", HeidenhainPlcArea.Dword), ("IW", HeidenhainPlcArea.InputWord), ("OW", HeidenhainPlcArea.OutputWord) }) + { + var region = layout.Region(area)!; + long expectedBytes = token.Length == 1 ? 10000 : 1000; + region.Bytes.ShouldBe(expectedBytes, $"{machine} {area}"); + var lastOffset = expectedBytes - region.Width; + Lsv2Data.DataPath(await client.ReadDataPathAsync($"/PLC/memory/{token}/{lastOffset}", Ct)).ShouldNotBeNull(); + (await Should.ThrowAsync(() => client.ReadDataPathAsync($"/PLC/memory/{token}/{expectedBytes}", Ct))).Code.ShouldBe(24); + HeidenhainBindingCodec.Range(area, lastOffset / region.Width, layout).Address.ShouldBe(region.Base + lastOffset); + Should.Throw(() => HeidenhainBindingCodec.Range(area, lastOffset / region.Width + 1, layout)); + } + + foreach (var token in new[] { "W", "D", "IW", "OW" }) + (await Should.ThrowAsync(() => client.ReadDataPathAsync($"/PLC/memory/{token}/1", Ct))).Code.ShouldBe(24); + + foreach (var (token, area, entries) in new[] + { + ("M", HeidenhainPlcArea.Marker, 10000), ("I", HeidenhainPlcArea.Input, 1000), ("O", HeidenhainPlcArea.Output, 1000), + ("C", HeidenhainPlcArea.Counter, 144), ("T", HeidenhainPlcArea.Timer, 1000), ("S", HeidenhainPlcArea.String, 100), + }) + { + var region = layout.Region(area)!; + region.Bytes.ShouldBe((long)entries * region.Width); + Lsv2Data.DataPath(await client.ReadDataPathAsync($"/PLC/memory/{token}/{entries - 1}", Ct)).ShouldNotBeNull(); + (await Should.ThrowAsync(() => client.ReadDataPathAsync($"/PLC/memory/{token}/{entries}", Ct))).Code.ShouldBe(24); + } + + // Observed FF-true on a real output slot. + if (f.TryGetValue(machine + ".O.999", out var o999) && o999.EndsWith("ff", StringComparison.OrdinalIgnoreCase)) + Lsv2Data.DataPath(await client.ReadDataPathAsync("/PLC/memory/O/999", Ct)).ShouldBe(true); + control.Violations.ShouldBeEmpty(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainSessionTests.cs new file mode 100644 index 000000000..830620649 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainSessionTests.cs @@ -0,0 +1,408 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// Session read-policy regression ported from the reference HeidenhainSessionTest. +[Trait("Category", "Unit")] +public sealed class HeidenhainSessionTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private sealed class Harness : IAsyncDisposable + { + public required Lsv2Simulator Sim { get; init; } + public required SimulatedControl Control { get; init; } + public required HeidenhainSession Session { get; init; } + public required IReadOnlyList Tags { get; init; } + + public async Task> ReadAsync(params string[] addresses) + { + var tags = addresses.Select(a => Tags.First(t => t.Address.Text == a || t.RawPath.EndsWith("/" + a, StringComparison.Ordinal))).ToList(); + var samples = await Session.ReadBatchAsync(tags, Ct); + var map = new Dictionary(); + for (var i = 0; i < tags.Count; i++) map[addresses[i]] = samples[i]; + return map; + } + + public int RuntimeRequests(int? selector = null) => + Sim.Requests.Count(r => r.Command == "R_RI" && (selector is null || r.Selector == selector)); + + public async ValueTask DisposeAsync() + { + await Session.DisposeAsync(); + Control.Violations.ShouldBeEmpty(); + await Sim.DisposeAsync(); + } + } + + private static async Task OpenAsync( + SimulatedControl control, IReadOnlyList tags, + bool telemetry = true, bool dnc = true, bool data = false, int maxAlarms = 64, bool timers = false, + HeidenhainSession.PasswordSource? passwords = null) + { + var sim = Lsv2Simulator.WithControl(control); + var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + var settings = new HeidenhainSessionSettings(telemetry, dnc, data, maxAlarms, timers, + tags.Any(t => t.Address.Kind == HeidenhainAddressKind.Plc), tags.Any(t => t.Address.Kind == HeidenhainAddressKind.DataPath)); + var session = new HeidenhainSession(client, settings, passwords ?? ((_, _) => ValueTask.FromResult(""))); + await client.OpenAsync(Ct); + await session.InitializeAsync(Ct); + return new Harness { Sim = sim, Control = control, Session = session, Tags = tags }; + } + + private static List Standard(params string[] addresses) => addresses.Select(a => TestTags.Def(a)).ToList(); + + private static SimReply DenyDnc(string role, string _) => role == "DNC" ? SimReply.Refuse(23) : SimReply.Ok("T_OK", []); + + [Fact] + public async Task Batch_shares_reads_and_preserves_unknown_native_state() + { + var c = new SimulatedControl(); + var program = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(program, 5); + "A\0B\0"u8.ToArray().CopyTo(program, 4); + c.Runtime[24] = () => SimReply.Ok("S_RI", program); + c.Runtime[25] = () => SimReply.Ok("S_RI", new byte[12]); + c.Runtime[23] = () => SimReply.Ok("S_RI", [1, 44]); + await using var h = await OpenAsync(c, Standard("Program/Main", "Program/Current", "Program/Line", "Overrides/Feed", "Overrides/Spindle", "Status/ExecutionState")); + var v = await h.ReadAsync("Program/Main", "Program/Current", "Program/Line", "Overrides/Feed", "Overrides/Spindle", "Status/ExecutionState"); + v.Values.ShouldAllBe(s => s.Status == HeidenhainStatus.Good); + h.RuntimeRequests(24).ShouldBe(1); + h.RuntimeRequests(25).ShouldBe(1); + v["Status/ExecutionState"].Value.ShouldBe(300); // raw enum preserved, not mapped + v["Program/Line"].Value.ShouldBe(5L); + v["Program/Current"].Value.ShouldBe("B"); + } + + [Theory] + [InlineData(57, true)] + [InlineData(23, false)] + [InlineData(24, false)] + [InlineData(25, false)] + public async Task Only_refusal_57_proves_an_empty_alarm_list(int code, bool empty) + { + var c = new SimulatedControl(); + c.Runtime[27] = () => SimReply.Refuse(code); + await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Messages")); + var v = await h.ReadAsync("Alarms/Count", "Alarms/Messages"); + if (empty) + { + v["Alarms/Count"].Value.ShouldBe(0); + v["Alarms/Messages"].Value.ShouldBe(Array.Empty()); + v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.Good); + } + else + { + v.Values.ShouldAllBe(s => s.Status != HeidenhainStatus.Good && s.Value == null); + } + } + + private static SimReply AlarmRecord() + { + var b = new byte[10]; + BinaryPrimitives.WriteInt16BigEndian(b, 3); + BinaryPrimitives.WriteInt16BigEndian(b.AsSpan(2), 4); + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(4), -1); + b[8] = 65; + return SimReply.Ok("S_RI", b); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task Alarm_cursor_end_at_the_limit_is_good(int limit) + { + var c = new SimulatedControl(); + c.Runtime[27] = AlarmRecord; + c.Runtime[28] = () => SimReply.Refuse(57); + await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Numbers", "Alarms/Classes"), maxAlarms: limit); + var v = await h.ReadAsync("Alarms/Count", "Alarms/Numbers", "Alarms/Classes"); + v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.Good); + v["Alarms/Count"].Value.ShouldBe(1); + v["Alarms/Numbers"].Value.ShouldBe(new[] { -1 }); + v["Alarms/Classes"].Value.ShouldBe(new[] { 3 }); + } + + [Fact] + public async Task Alarm_limit_exhaustion_is_out_of_range_never_truncated() + { + var c = new SimulatedControl(); + c.Runtime[27] = AlarmRecord; + c.Runtime[28] = AlarmRecord; + await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Messages"), maxAlarms: 1); + var v = await h.ReadAsync("Alarms/Count", "Alarms/Messages"); + v.Values.ShouldAllBe(s => s.Status == HeidenhainStatus.BadOutOfRange && s.Value == null); + } + + [Fact] + public async Task Malformed_alarm_record_is_a_decoding_error_not_an_empty_list() + { + var c = new SimulatedControl(); + c.Runtime[27] = () => SimReply.Ok("S_RI", [0, 0, 0, 0, 0, 0, 0, 0, 65]); // unterminated text + await using var h = await OpenAsync(c, Standard("Alarms/Count", "Capabilities/Alarms")); + var v = await h.ReadAsync("Alarms/Count", "Capabilities/Alarms"); + v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.BadDecodingError); + v["Capabilities/Alarms"].Value.ShouldBe("InvalidData"); + } + + [Fact] + public async Task Denied_dnc_leaves_inspect_telemetry_available_and_honours_each_read_refusal() + { + var c = new SimulatedControl { Login = DenyDnc }; + c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]); + c.Runtime[25] = () => SimReply.Refuse(23); + await using var h = await OpenAsync(c, Standard("Controller/Model", "Status/ExecutionState", "Overrides/Feed", "Capabilities/DncLogin", "Capabilities/Overrides")); + var v = await h.ReadAsync("Controller/Model", "Status/ExecutionState", "Overrides/Feed", "Capabilities/DncLogin", "Capabilities/Overrides"); + v["Controller/Model"].Status.ShouldBe(HeidenhainStatus.Good); + v["Status/ExecutionState"].Value.ShouldBe(4); + v["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied); + v["Capabilities/DncLogin"].Value.ShouldBe("AccessDenied"); + v["Capabilities/Overrides"].Value.ShouldBe("AccessDenied"); + h.RuntimeRequests().ShouldBe(2); + // a stable denial is suppressed for the rest of the session + (await h.ReadAsync("Overrides/Feed"))["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied); + h.RuntimeRequests().ShouldBe(2); + } + + [Fact] + public async Task Telemetry_opt_out_prevents_every_runtime_request() + { + var c = new SimulatedControl(); + c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]); + await using var h = await OpenAsync(c, Standard("Status/ExecutionState", "Capabilities/Execution", "Capabilities/DncLogin"), telemetry: false); + var v = await h.ReadAsync("Status/ExecutionState", "Capabilities/Execution", "Capabilities/DncLogin"); + v["Status/ExecutionState"].Status.ShouldBe(HeidenhainStatus.BadNotSupported); + v["Capabilities/Execution"].Value.ShouldBe("Disabled"); + v["Capabilities/DncLogin"].Value.ShouldBe("Disabled"); + h.RuntimeRequests().ShouldBe(0); + h.Control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT" }); + } + + [Fact] + public async Task Experimental_timers_are_disabled_by_default_and_never_requested() + { + var c = new SimulatedControl(); + c.Runtime[30] = () => SimReply.Ok("S_RI", [0, 0, 0, 9]); + await using var h = await OpenAsync(c, Standard("Timers/NcUptime", "Capabilities/Timer_NcUptime")); + var v = await h.ReadAsync("Timers/NcUptime", "Capabilities/Timer_NcUptime"); + v["Timers/NcUptime"].Status.ShouldBe(HeidenhainStatus.BadNotSupported); + v["Capabilities/Timer_NcUptime"].Value.ShouldBe("Disabled"); + h.RuntimeRequests(30).ShouldBe(0); + } + + [Fact] + public async Task Enabled_timers_read_raw_uint32() + { + var c = new SimulatedControl(); + c.Runtime[34] = () => SimReply.Ok("S_RI", [0xFF, 0xFF, 0xFF, 0xFF]); + await using var h = await OpenAsync(c, Standard("Timers/Spindle1Runtime"), timers: true); + (await h.ReadAsync("Timers/Spindle1Runtime"))["Timers/Spindle1Runtime"].Value.ShouldBe(4294967295L); + } + + [Fact] + public async Task Tool_without_geometry_is_not_supported_not_zero() + { + var c = new SimulatedControl(); + c.Runtime[51] = () => SimReply.Ok("S_RI", [0, 0, 0, 7, 0, 1, 0, 2]); + await using var h = await OpenAsync(c, Standard("Tool/Number", "Tool/Index", "Tool/AxisCode", "Tool/Length", "Tool/Radius")); + var v = await h.ReadAsync("Tool/Number", "Tool/Index", "Tool/AxisCode", "Tool/Length", "Tool/Radius"); + v["Tool/Number"].Value.ShouldBe(7L); + v["Tool/Index"].Value.ShouldBe(1); + v["Tool/AxisCode"].Value.ShouldBe(2); + v["Tool/Length"].Status.ShouldBe(HeidenhainStatus.BadNotSupported); + v["Tool/Length"].Value.ShouldBeNull(); + v["Tool/Radius"].Status.ShouldBe(HeidenhainStatus.BadNotSupported); + } + + [Theory] + [InlineData("hermle49")] + [InlineData("hermle59")] + public async Task Captured_inspect_only_replies_publish_typed_telemetry_after_dnc_denial(string machine) + { + var f = Fixtures.Load("itnc530-inspect.properties"); + var c = new SimulatedControl { Login = DenyDnc }; + foreach (var s in new[] { 22, 23, 24, 25, 26, 27, 28, 51 }) + { + var reply = SimReply.FromFixture(f[$"{machine}.{s}"]); + c.Runtime[s] = () => reply; + } + + string[] addresses = ["Status/ExecutionState", "Status/ProgramState", "Program/Main", "Axes/Names", "Axes/Positions", + "Alarms/Count", "Alarms/Messages", "Alarms/Classes", "Overrides/Feed", "Tool/Number"]; + await using var h = await OpenAsync(c, Standard(addresses)); + var v = await h.ReadAsync(addresses); + v["Status/ExecutionState"].Value.ShouldBe(4); + v["Status/ProgramState"].Value.ShouldBe(7); + ((string)v["Program/Main"].Value!).ShouldStartWith("TNC:"); + v["Axes/Names"].Value.ShouldBe(new[] { "X", "Y", "Z", "C", "a", "A" }); + ((double[])v["Axes/Positions"].Value!).Length.ShouldBe(6); + v["Alarms/Count"].Value.ShouldBe(1); + v["Alarms/Messages"].Value.ShouldBe(new[] { "Run test of cutout channels" }); + v["Alarms/Classes"].Value.ShouldBe(new[] { 7 }); + v["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied); + v["Tool/Number"].Status.ShouldBe(machine == "hermle49" ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.Good); + } + + [Fact] + public async Task Data_path_type_mismatch_is_bad_and_aliases_share_one_read() + { + var c = new SimulatedControl(); + c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]); + var tags = new List + { + TestTags.Def("DATA:/x", "Int64", "Plant/H/dev1/A"), + TestTags.Def("DATA:/x", "String", "Plant/H/dev1/B"), + }; + await using var h = await OpenAsync(c, tags, telemetry: false, data: true); + var samples = await h.Session.ReadBatchAsync(tags, Ct); + samples[0].Value.ShouldBe(42L); + samples[1].Status.ShouldBe(HeidenhainStatus.BadTypeMismatch); + h.Sim.Requests.Count(r => r.Command == "R_DP").ShouldBe(1); + h.Control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT", "DATA" }); + } + + [Fact] + public async Task Data_bindings_stay_disabled_until_data_access_is_enabled() + { + var c = new SimulatedControl(); + c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]); + var tags = new List { TestTags.Def("DATA:/x", "Int64"), TestTags.Def("Capabilities/DataLogin") }; + await using var h = await OpenAsync(c, tags, data: false); + var s = await h.Session.ReadBatchAsync(tags, Ct); + s[0].Status.ShouldBe(HeidenhainStatus.BadNotSupported); + s[1].Value.ShouldBe("Disabled"); + h.Sim.Requests.ShouldNotContain(r => r.Command == "R_DP"); + h.Control.Logins.ShouldNotContain(l => l.Role == "DATA"); + } + + [Fact] + public async Task Transport_failure_invalidates_the_batch_and_close_prevents_good_reads() + { + var c = new SimulatedControl(); + c.Runtime[23] = SimReply.Drop; + var tags = Standard("Controller/Model", "Status/ExecutionState", "Capabilities/Identity"); + await using var h = await OpenAsync(c, tags); + var v = await h.ReadAsync("Controller/Model", "Status/ExecutionState", "Capabilities/Identity"); + h.Session.CommunicationLost.ShouldBeTrue(); + v["Controller/Model"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication); + v["Status/ExecutionState"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication); + v["Capabilities/Identity"].Value.ShouldBe("CommunicationLost"); + await h.Session.DisposeAsync(); + (await h.ReadAsync("Controller/Model"))["Controller/Model"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication); + } + + [Theory] + [InlineData("DNC")] + [InlineData("DATA")] + public async Task Temporary_optional_login_refusal_retries_with_a_fresh_credential(string role) + { + var attempts = 0; + var c = new SimulatedControl + { + Login = (r, pw) => r == role && Interlocked.Increment(ref attempts) == 1 ? SimReply.Refuse(25) : SimReply.Ok("T_OK", []), + }; + c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 3]); + c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]); + var resolved = 0; + var tags = role == "DATA" ? [TestTags.Def("DATA:/x", "Int64")] : Standard("Status/ExecutionState"); + await using var h = await OpenAsync(c, tags, data: true, + passwords: (r, _) => { if (r == role) Interlocked.Increment(ref resolved); return ValueTask.FromResult("fresh"); }); + var s = await h.Session.ReadBatchAsync(tags, Ct); + s[0].Status.ShouldBe(HeidenhainStatus.Good); + attempts.ShouldBe(2); + resolved.ShouldBe(2); // resolved on demand for each login — never cached + h.Control.Logins.Where(l => l.Role == role).ShouldAllBe(l => l.Password == "fresh"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Temporary_or_malformed_layout_recovers_and_aliases_read_once_per_batch(bool malformed) + { + var layouts = 0; + var c = new SimulatedControl + { + Intercept = r => r.Command == "R_PR" && Interlocked.Increment(ref layouts) == 1 + ? (malformed ? SimReply.Ok("S_PR", new byte[1]) : SimReply.Refuse(25)) + : null, + Memory = (_, n) => SimReply.Ok("S_MB", [42, 0, 0, 0]), + }; + var tags = new List + { + TestTags.Def("PLC:DWORD:0", "Int64", "Plant/H/dev1/A"), + TestTags.Def("PLC:DWORD:0", "Int32", "Plant/H/dev1/B"), + }; + await using var h = await OpenAsync(c, tags, telemetry: false); + var s = await h.Session.ReadBatchAsync(tags, Ct); + s.ShouldAllBe(x => x.Status == HeidenhainStatus.Good); + s[0].Value.ShouldBe(42L); + s[1].Value.ShouldBe(42); + layouts.ShouldBe(2); + h.Sim.Requests.Count(r => r.Command == "R_MB").ShouldBe(1); + } + + [Fact] + public async Task Plc_binding_outside_the_advertised_region_is_out_of_range_without_a_wire_read() + { + var c = new SimulatedControl { Memory = (_, _) => SimReply.Ok("S_MB", [0, 0]) }; + var tags = new List { TestTags.Def("PLC:WORD:5", "Int32"), TestTags.Def("PLC:WORD:4", "Int32") }; + await using var h = await OpenAsync(c, tags, telemetry: false); + var s = await h.Session.ReadBatchAsync(tags, Ct); + s[0].Status.ShouldBe(HeidenhainStatus.BadOutOfRange); + s[1].Status.ShouldBe(HeidenhainStatus.Good); + var mb = h.Sim.Requests.Single(r => r.Command == "R_MB"); + BinaryPrimitives.ReadUInt32BigEndian(mb.Payload).ShouldBe(1008u); + mb.Payload[4].ShouldBe((byte)2); + } + + [Fact] + public async Task Plc_memory_denial_is_access_denied_and_suppressed() + { + var c = new SimulatedControl(); // R_MB → T_ER 23, as on all four tested iTNC 530 controls + var tags = new List { TestTags.Def("PLC:WORD:1", "Int32") }; + await using var h = await OpenAsync(c, tags, telemetry: false); + (await h.Session.ReadBatchAsync(tags, Ct))[0].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied); + (await h.Session.ReadBatchAsync(tags, Ct))[0].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied); + h.Sim.Requests.Count(r => r.Command == "R_MB").ShouldBe(1); + } + + [Fact] + public async Task Repeated_transient_dnc_failures_cannot_permanently_suppress_telemetry() + { + var logins = 0; + var c = new SimulatedControl + { + Login = (r, _) => r == "DNC" && Interlocked.Increment(ref logins) <= 2 ? SimReply.Refuse(25) : SimReply.Ok("T_OK", []), + }; + c.Runtime[23] = () => logins < 3 ? SimReply.Refuse(23) : SimReply.Ok("S_RI", [0, 4]); + c.Runtime[26] = () => logins < 3 ? SimReply.Refuse(23) : SimReply.Ok("S_RI", [0, 7]); + await using var h = await OpenAsync(c, Standard("Status/ExecutionState", "Status/ProgramState")); + (await h.ReadAsync("Status/ExecutionState", "Status/ProgramState")).Values + .ShouldAllBe(s => s.Status == HeidenhainStatus.BadUserAccessDenied); + var recovered = await h.ReadAsync("Status/ExecutionState", "Status/ProgramState"); + recovered["Status/ExecutionState"].Value.ShouldBe(4); + recovered["Status/ProgramState"].Value.ShouldBe(7); + logins.ShouldBe(3); + } + + [Fact] + public async Task Every_request_the_session_emits_is_allowlisted() + { + var c = new SimulatedControl(); + foreach (var s in new[] { 22, 23, 24, 25, 26, 51 }) c.Runtime[s] = () => SimReply.Refuse(24); + c.Runtime[27] = () => SimReply.Refuse(57); + var tags = HeidenhainCatalog.Entries.Select(e => TestTags.Def(e.Address)).ToList(); + tags.Add(TestTags.Def("PLC:WORD:1", "Int32")); + await using var h = await OpenAsync(c, tags, timers: true); + await h.Session.ReadBatchAsync(tags, Ct); + await h.Session.ReadBatchAsync(tags, Ct); + h.Sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB" }); + h.Sim.Requests.Where(r => r.Command == "A_LG").Select(r => Encoding.Latin1.GetString(r.Payload).Split('\0')[0]) + .ShouldBeSubsetOf(new[] { "INSPECT", "DNC", "DATA" }); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs new file mode 100644 index 000000000..3f445bee1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs @@ -0,0 +1,183 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// Decoder regression ported from the reference Lsv2DataTest + HeidenhainBindingsTest. +[Trait("Category", "Unit")] +public sealed class Lsv2DataTests +{ + private static byte[] T(string s) => Encoding.Latin1.GetBytes(s); + + [Fact] + public void Version_and_program_preserve_native_values() + { + Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0OPT\0")).Model.ShouldBe("iTNC 530"); + Lsv2Data.Versions(T("TNC\0NC\0PLC\0SPLC\0OPT\0")).Options.ShouldBe("OPT"); + var program = new byte[10]; + BinaryPrimitives.WriteInt32BigEndian(program, -1); + T("A\0B\0\0\0").CopyTo(program, 4); + var p = Lsv2Data.Program(program); + p.Line.ShouldBe(4294967295L); + p.Current.ShouldBe("B"); + Lsv2Data.U16([0xFF, 0xFE]).ShouldBe(65534); + } + + [Fact] + public void Malformed_strings_and_lengths_are_rejected() + { + Should.Throw(() => Lsv2Data.Versions(T("TNC\0NC\0PLC\0OPT"))); + Should.Throw(() => Lsv2Data.Versions(T("TNC\0NC\0"))); + Should.Throw(() => Lsv2Data.Program(new byte[4])); + Should.Throw(() => Lsv2Data.Overrides(new byte[11])); + Should.Throw(() => Lsv2Data.Layout(new byte[119])); + Should.Throw(() => Lsv2Data.U16(new byte[3])); + Should.Throw(() => Lsv2Data.Alarm(new byte[8])); + } + + [Fact] + public void Empty_version_options_do_not_become_the_optional_splc_field() + { + Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0SPLC\0\0")).Options.ShouldBe(""); + Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0\0")).Options.ShouldBe(""); + Should.Throw(() => Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0SPLC\0OPT\0\0"))); + } + + [Fact] + public void Overrides_are_unsigned_hundredths_and_tool_is_mixed_endian() + { + var o = new byte[12]; + BinaryPrimitives.WriteInt32BigEndian(o, 12345); + BinaryPrimitives.WriteInt32BigEndian(o.AsSpan(4), 8000); + Lsv2Data.Overrides(o).ShouldBe(new[] { 123.45, 80, 0 }); + var t = new byte[24]; + BinaryPrimitives.WriteInt32BigEndian(t, -1); + BinaryPrimitives.WriteInt16BigEndian(t.AsSpan(4), 4); + BinaryPrimitives.WriteInt16BigEndian(t.AsSpan(6), 2); + BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), 123.5); + BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(16), 4.25); + var tool = Lsv2Data.Tool(t); + tool.Number.ShouldBe(4294967295L); + tool.Length.ShouldBe(123.5); + tool.Radius.ShouldBe(4.25); + Lsv2Data.Tool(new byte[8]).Length.ShouldBeNull(); + Should.Throw(() => Lsv2Data.Tool(new byte[9])); + BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), double.NaN); + Should.Throw(() => Lsv2Data.Tool(t)); + } + + [Fact] + public void Axes_are_aligned_with_no_guessed_units() + { + var a = Lsv2Data.Axes(T("\0\u0002-1.25\u00002.5\0X\0C\0")); + a.Names.ShouldBe(new[] { "X", "C" }); + a.Positions.ShouldBe(new[] { -1.25, 2.5 }); + Should.Throw(() => Lsv2Data.Axes(T("\0\u0002NaN\u00002\0X\0Y\0"))); + Should.Throw(() => Lsv2Data.Axes(T("\0\u00021\u00002\0X\0X\0"))); + Should.Throw(() => Lsv2Data.Axes(T("\0\u00021\u00002\0X\0"))); + Should.Throw(() => Lsv2Data.Axes(T("\0\u0002abc\u00002\0X\0Y\0"))); + } + + [Fact] + public void Error_number_is_signed_and_data_path_types_are_strict() + { + var e = new byte[12]; + BinaryPrimitives.WriteInt16BigEndian(e, 3); + BinaryPrimitives.WriteInt16BigEndian(e.AsSpan(2), 4); + BinaryPrimitives.WriteInt32BigEndian(e.AsSpan(4), -10); + T("Err\0").CopyTo(e, 8); + Lsv2Data.Alarm(e).Number.ShouldBe(-10); + Lsv2Data.DataPath([0, 0, 0, 2, 0xFF, 0xFE]).ShouldBe(-2); + Lsv2Data.DataPath([0, 0, 0, 17, 0xFF]).ShouldBe(255); + Lsv2Data.DataPath([0, 0, 0, 16, 0xFF]).ShouldBe(-1); + Lsv2Data.DataPath([0, 0, 0, 11, 1]).ShouldBe(true); + Lsv2Data.DataPath([0, 0, 0, 8, 65, 66, 0]).ShouldBe("AB"); + Should.Throw(() => Lsv2Data.DataPath([0, 0, 0, 5])); + Should.Throw(() => Lsv2Data.DataPath([0, 0, 0, 99, 1])); + var inf = new byte[12]; + BinaryPrimitives.WriteInt32BigEndian(inf, 5); + BinaryPrimitives.WriteDoubleLittleEndian(inf.AsSpan(4), double.PositiveInfinity); + Should.Throw(() => Lsv2Data.DataPath(inf)); + } + + [Fact] + public void Data_path_boolean_accepts_the_real_controls_ff_true_encoding() + { + Lsv2Data.DataPath([0, 0, 0, 11, 0xFF]).ShouldBe(true); + Lsv2Data.DataPath([0, 0, 0, 11, 0]).ShouldBe(false); + Should.Throw(() => Lsv2Data.DataPath([0, 0, 0, 11, 2])); + } + + [Fact] + public void Numeric_counts_are_bytes_even_when_no_adjacent_area_caps_them() + { + foreach (var length in new[] { 120, 124 }) + { + var bytes = new byte[length]; + SimulatedControl.LayoutBytes(10).CopyTo(bytes, 0); + var layout = Lsv2Data.Layout(bytes); + foreach (var area in new[] { HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, HeidenhainPlcArea.OutputWord }) + layout.Region(area)!.Bytes.ShouldBe(10, area.ToString()); + layout.Region(HeidenhainPlcArea.String)!.Bytes.ShouldBe(160); + layout.Region(HeidenhainPlcArea.Word)!.Base.ShouldBe(1000); + } + } + + [Fact] + public void Memory_range_uses_byte_capacity_and_element_index() + { + var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10)); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 1, layout).ShouldBe(new HeidenhainMemoryRange(1004, 4)); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 2, layout)); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 9, layout).Address.ShouldBe(1009); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 10, layout)); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 4, layout).Address.ShouldBe(1008); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 5, layout)); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 4, layout).Address.ShouldBe(3008); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 5, layout)); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 4, layout).Address.ShouldBe(4008); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 5, layout)); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 0, null)); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 0xFFFF_FFFFL, layout)); + } + + [Fact] + public void Memory_and_runtime_coercion_cannot_truncate_or_wrap() + { + HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, [0xFE, 0xFF, 0xFF, 0xFF], DriverDataType.Int64).ShouldBe(-2L); + HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputWord, [0xFF, 0xFF], DriverDataType.Int32).ShouldBe(65535); + HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Word, [0xFF, 0xFF], DriverDataType.Int16).ShouldBe((short)-1); + HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [1], DriverDataType.Boolean).ShouldBe(true); + Should.Throw(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [0xFF], DriverDataType.Boolean)); + Should.Throw(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, new byte[3], DriverDataType.Int64)); + Should.Throw(() => HeidenhainBindingCodec.Coerce(1.5, DriverDataType.Int32)); + Should.Throw(() => HeidenhainBindingCodec.Coerce(32768, DriverDataType.Int16)); + Should.Throw(() => HeidenhainBindingCodec.Coerce(1, DriverDataType.Boolean)); + Should.Throw(() => HeidenhainBindingCodec.Coerce(1, DriverDataType.String)); + Should.Throw(() => HeidenhainBindingCodec.Coerce(double.NaN, DriverDataType.Float64)); + Should.Throw(() => HeidenhainBindingCodec.Coerce("x", DriverDataType.Int64)); + HeidenhainBindingCodec.Coerce(2.0, DriverDataType.Int64).ShouldBe(2L); + HeidenhainBindingCodec.Coerce(7, DriverDataType.Float64).ShouldBe(7.0); + } + + [Theory] + [InlineData("hermle49")] + [InlineData("hermle59")] + public void Captured_layouts_reject_numeric_reads_at_the_next_advertised_area(string machine) + { + var f = Fixtures.Load("itnc530-inspect.properties"); + var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"])); + foreach (var (area, index) in new[] { (HeidenhainPlcArea.Byte, 10000L), (HeidenhainPlcArea.Word, 5000L), (HeidenhainPlcArea.Dword, 2500L), (HeidenhainPlcArea.InputWord, 500L), (HeidenhainPlcArea.OutputWord, 500L) }) + Should.Throw(() => HeidenhainBindingCodec.Range(area, index, layout), $"{machine} {area}:{index}"); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 9999, layout).Address.ShouldBe(10063); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 4999, layout).Address.ShouldBe(10062); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 2499, layout).Address.ShouldBe(10060); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 499, layout).Address.ShouldBe(11062); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 499, layout).Address.ShouldBe(12062); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2ReadOnlyPolicyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2ReadOnlyPolicyTests.cs new file mode 100644 index 000000000..2935e6679 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2ReadOnlyPolicyTests.cs @@ -0,0 +1,143 @@ +using System.Reflection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// +/// The payload-aware outbound allowlist (ported from the reference rejectsEveryWriteAndElevatedRole / +/// validatesSelectorsMemoryAndPathsBeforeTransmission), plus proof that a refused request never puts a +/// single byte on the wire and that the driver has no write surface at all. +/// +[Trait("Category", "Unit")] +public sealed class Lsv2ReadOnlyPolicyTests +{ + public static TheoryData WriteAndControlCommands() => + [ + // file / PLC / parameter / buffer writes and transfers, logout, file reads, key & scope operations, + // and response mnemonics that must never be sent as requests. + "C_CC", "C_FL", "C_MB", "C_DC", "C_DD", "C_FD", "C_FC", "C_PR", "C_EK", "C_LK", "C_MC", "C_TP", + "A_LO", "R_MC", "R_FL", "R_DI", "R_FI", "R_DR", "R_ST", "R_TP", "R_OC", "T_OK", "T_ER", "S_RI", "XXXX", + ]; + + [Theory] + [MemberData(nameof(WriteAndControlCommands))] + public void Every_non_allowlisted_command_is_refused(string command) + { + Should.Throw(() => Lsv2ReadOnlyPolicy.Check(command, [])); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check(command, [0, 22])); + Should.Throw(() => Lsv2Codec.Frame(command, [])); + } + + [Theory] + [InlineData("PLCDEBUG")] + [InlineData("MONITOR")] + [InlineData("FILE")] + [InlineData("DIAGNOSTICS")] + [InlineData("DNC\0PLCDEBUG")] + [InlineData("inspect")] + [InlineData("")] + public void Elevated_or_unknown_roles_are_refused(string role) => + Should.Throw(() => Lsv2ReadOnlyPolicy.LoginPayload(role, "")); + + [Fact] + public void Login_payloads_are_checked_for_embedded_nul_and_role_smuggling() + { + Should.Throw(() => Lsv2ReadOnlyPolicy.LoginPayload("DNC", "x\0y")); + // a hand-built payload that names an elevated role after a permitted one + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("A_LG", "PLCDEBUG\0"u8.ToArray())); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("A_LG", "INSPECT"u8.ToArray())); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("A_LG", "INSPECT\0a\0b\0"u8.ToArray())); + Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", "").ShouldBe("INSPECT\0"u8.ToArray()); + Lsv2ReadOnlyPolicy.LoginPayload("DATA", "pw").ShouldBe("DATA\0pw\0"u8.ToArray()); + } + + [Theory] + [InlineData(0)] + [InlineData(21)] + [InlineData(29)] + [InlineData(35)] + [InlineData(50)] + [InlineData(52)] + [InlineData(0xFFFF)] + public void Runtime_selectors_outside_the_allowlist_are_refused(int selector) => + Should.Throw(() => Lsv2ReadOnlyPolicy.RuntimePayload(selector)); + + [Fact] + public void Allowlisted_selectors_pass_and_payload_shape_is_enforced() + { + foreach (var s in new[] { 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51 }) + Lsv2ReadOnlyPolicy.RuntimePayload(s).Length.ShouldBe(2); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("R_RI", [0, 23, 0])); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("R_VR", [0])); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("R_PR", [1, 2])); + } + + [Fact] + public void Memory_reads_are_bounded_and_cannot_wrap() + { + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("R_MB", new byte[5])); // zero count + Should.Throw(() => Lsv2ReadOnlyPolicy.MemoryPayload(0, 248)); + Should.Throw(() => Lsv2ReadOnlyPolicy.MemoryPayload(-1, 1)); + Should.Throw(() => Lsv2ReadOnlyPolicy.MemoryPayload(0xFFFF_FFFF, 2)); // wraps + Lsv2ReadOnlyPolicy.MemoryPayload(0xFFFF_FFFF, 1).ShouldBe(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 1 }); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("R_MB", new byte[6])); + } + + [Fact] + public void Data_paths_are_validated_before_transmission() + { + Should.Throw(() => Lsv2ReadOnlyPolicy.DataPathPayload("/a\0/b")); + Should.Throw(() => Lsv2ReadOnlyPolicy.DataPathPayload("/a/€")); + Should.Throw(() => Lsv2ReadOnlyPolicy.DataPathPayload(new string('/', 244))); + Should.Throw(() => Lsv2ReadOnlyPolicy.DataPathPayload(" ")); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("R_DP", [0, 0, 0, 1, 65, 0])); // nonzero prefix + Lsv2ReadOnlyPolicy.DataPathPayload("/PLC/memory/W/2").ShouldBe("\0\0\0\0\\PLC\\memory\\W\\2\0"u8.ToArray()); + } + + [Fact] + public async Task A_refused_request_never_reaches_the_socket() + { + var received = 0; + await using var sim = Lsv2Simulator.WithScript(async (c, ct) => + { + while (await c.ReadByteAsync(ct) >= 0) Interlocked.Increment(ref received); + }); + var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + await client.OpenAsync(TestContext.Current.CancellationToken); + foreach (var cmd in new[] { "C_CC", "C_MB", "A_LO", "R_FL" }) + await Should.ThrowAsync(() => client.ExchangeAsync(cmd, [], "T_OK", TestContext.Current.CancellationToken)); + await Should.ThrowAsync(() => client.ExchangeAsync("R_RI", [0, 29], "S_RI", TestContext.Current.CancellationToken)); + await Should.ThrowAsync(() => client.ExchangeAsync("A_LG", "PLCDEBUG\0"u8.ToArray(), "T_OK", TestContext.Current.CancellationToken)); + client.RequestsSent.ShouldBe(0); + client.IsClosed.ShouldBeFalse(); // a policy refusal is not a transport failure + client.Close(); + await Task.Delay(100, TestContext.Current.CancellationToken); + Volatile.Read(ref received).ShouldBe(0); + } + + [Fact] + public void Client_public_surface_has_no_raw_command_api() + { + var methods = typeof(Lsv2Client).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName).Select(m => m.Name).OrderBy(n => n).ToArray(); + methods.ShouldBe(new[] + { + "Close", "DisposeAsync", "LoginAsync", "OpenAsync", "ReadDataPathAsync", "ReadMemoryAsync", + "ReadParametersAsync", "ReadRuntimeAsync", "ReadVersionAsync", + }); + typeof(Lsv2Client).GetMethod("ExchangeAsync", BindingFlags.Public | BindingFlags.Instance).ShouldBeNull(); + } + + [Fact] + public void Driver_has_no_write_capability() + { + typeof(IWritable).IsAssignableFrom(typeof(HeidenhainDriver)).ShouldBeFalse(); + typeof(HeidenhainDriver).GetMethods().ShouldNotContain(m => m.Name.Contains("Write", StringComparison.Ordinal)); + Lsv2ReadOnlyPolicy.Commands.ShouldBe(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB", "R_DP" }, ignoreOrder: true); + Lsv2ReadOnlyPolicy.Roles.ShouldBe(new[] { "INSPECT", "DNC", "DATA" }, ignoreOrder: true); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2TransportTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2TransportTests.cs new file mode 100644 index 000000000..f4a68fff2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2TransportTests.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// Framing/transport regression ported from the reference Lsv2TransportTest (literal frames, independent of the encoder). +[Trait("Category", "Unit")] +public sealed class Lsv2TransportTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static async Task OpenAsync(Lsv2Simulator sim, int timeoutMs = 1000) + { + var c = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromMilliseconds(timeoutMs)); + await c.OpenAsync(Ct); + return c; + } + + [Fact] + public async Task Literal_runtime_request_and_byte_fragmented_response() + { + byte[]? seen = null; + await using var sim = Lsv2Simulator.WithScript(async (c, ct) => + { + var req = await c.ReadRequestAsync(ct); + seen = SimConnection.Frame(req!.Command, req.Payload); + await c.WriteRawAsync(Convert.FromHexString("00000002535f52490003"), byteByByte: true, ct); + }); + await using var client = await OpenAsync(sim); + (await client.ReadRuntimeAsync(23, Ct)).ShouldBe(new byte[] { 0, 3 }); + seen.ShouldBe(Convert.FromHexString("00000002525f52490017")); + } + + [Fact] + public async Task Controller_refusal_preserves_code_and_stream_sync() + { + await using var sim = Lsv2Simulator.WithScript(async (c, ct) => + { + await c.ReadRequestAsync(ct); + await c.ReplyAsync("T_ER", [1, 23], ct); + await c.ReadRequestAsync(ct); + await c.ReplyAsync("S_VR", [65, 0], ct); + }); + await using var client = await OpenAsync(sim); + var refusal = await Should.ThrowAsync(() => client.ReadRuntimeAsync(23, Ct)); + refusal.Code.ShouldBe(23); + client.IsClosed.ShouldBeFalse(); + (await client.ReadVersionAsync(Ct)).ShouldBe(new byte[] { 65, 0 }); + } + + [Theory] + [InlineData("00010000535f5249")] // oversized length: rejected before allocation + [InlineData("00000000535f5652")] // unexpected response type (S_VR for an R_RI) + [InlineData("00000002535f524900")] // partial body then EOF + [InlineData("00000003545f455201")] // malformed refusal (3-byte T_ER) + [InlineData("00000001545f4f4b00")] // T_OK with a body + public async Task Bad_frames_close_the_client_and_later_requests_fail(string hex) + { + await using var sim = Lsv2Simulator.WithScript(async (c, ct) => + { + await c.ReadRequestAsync(ct); + await c.WriteRawAsync(Convert.FromHexString(hex), byteByByte: false, ct); + if (hex.EndsWith("00", StringComparison.Ordinal) && hex.Length == 18) c.Close(); + await Task.Delay(2000, ct); + }); + await using var client = await OpenAsync(sim); + var req = hex.Contains("545f4f4b", StringComparison.Ordinal) + ? client.LoginAsync("INSPECT", "", Ct) + : client.ReadRuntimeAsync(23, Ct); + await Should.ThrowAsync(() => req); + client.IsClosed.ShouldBeTrue(); + await Should.ThrowAsync(() => client.ReadVersionAsync(Ct)); + } + + [Fact] + public async Task Close_interrupts_a_blocked_read_without_waiting_for_the_gate() + { + var requested = new TaskCompletionSource(); + await using var sim = Lsv2Simulator.WithScript(async (c, ct) => + { + await c.ReadRequestAsync(ct); + requested.TrySetResult(); + await c.ReadByteAsync(ct); // never answers + }); + await using var client = await OpenAsync(sim, timeoutMs: 10_000); + var pending = client.ReadRuntimeAsync(23, Ct); + await requested.Task.WaitAsync(TimeSpan.FromSeconds(2), Ct); + var sw = Stopwatch.StartNew(); + client.Close(); + await Should.ThrowAsync(() => pending.WaitAsync(TimeSpan.FromSeconds(2), Ct)); + sw.ElapsedMilliseconds.ShouldBeLessThan(2000); + } + + [Fact] + public async Task Trickle_cannot_reset_the_transaction_deadline() + { + await using var sim = Lsv2Simulator.WithScript(async (c, ct) => + { + await c.ReadRequestAsync(ct); + for (var i = 0; i < 20; i++) + { + await c.WriteRawAsync([0], byteByByte: false, ct); + await Task.Delay(40, ct); + } + }); + await using var client = await OpenAsync(sim, timeoutMs: 120); + var sw = Stopwatch.StartNew(); + await Should.ThrowAsync(() => client.ReadRuntimeAsync(23, Ct)); + sw.ElapsedMilliseconds.ShouldBeLessThan(1000); + client.IsClosed.ShouldBeTrue(); + } + + [Fact] + public async Task Transactions_are_serialized_one_frame_at_a_time() + { + var concurrent = 0; + var maxConcurrent = 0; + await using var sim = Lsv2Simulator.WithScript(async (c, ct) => + { + while (await c.ReadRequestAsync(ct) is not null) + { + var now = Interlocked.Increment(ref concurrent); + maxConcurrent = Math.Max(maxConcurrent, now); + await Task.Delay(10, ct); + Interlocked.Decrement(ref concurrent); + await c.ReplyAsync("S_RI", [0, 1], ct); + } + }); + await using var client = await OpenAsync(sim); + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => client.ReadRuntimeAsync(23, Ct))); + maxConcurrent.ShouldBe(1); + } + + [Fact] + public async Task Connect_to_a_closed_port_is_a_transport_failure() + { + int port; + await using (var sim = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = sim.Port; + var client = new Lsv2Client("127.0.0.1", port, TimeSpan.FromMilliseconds(500)); + await Should.ThrowAsync(() => client.OpenAsync(Ct)); + client.IsClosed.ShouldBeTrue(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/Lsv2Simulator.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/Lsv2Simulator.cs new file mode 100644 index 000000000..98724183b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/Lsv2Simulator.cs @@ -0,0 +1,197 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +/// One request frame as the simulated control received it. +public sealed record Lsv2Request(string Command, byte[] Payload) +{ + /// The payload decoded as Latin-1 (for login/path assertions). + public string Text => Encoding.Latin1.GetString(Payload); + + /// The R_RI selector. + public int Selector => BinaryPrimitives.ReadUInt16BigEndian(Payload); +} + +/// A scripted connection to the simulator. +public sealed class SimConnection(TcpClient client) +{ + private readonly NetworkStream _stream = client.GetStream(); + + /// Reads one complete request frame, or null on EOF. + public async Task ReadRequestAsync(CancellationToken ct) + { + var header = new byte[8]; + var read = 0; + while (read < 8) + { + var n = await _stream.ReadAsync(header.AsMemory(read), ct).ConfigureAwait(false); + if (n == 0) return read == 0 ? null : throw new EndOfStreamException(); + read += n; + } + + var length = (int)BinaryPrimitives.ReadUInt32BigEndian(header); + var payload = new byte[length]; + await _stream.ReadExactlyAsync(payload, ct).ConfigureAwait(false); + return new Lsv2Request(Encoding.ASCII.GetString(header, 4, 4), payload); + } + + /// Writes one well-formed reply frame. + public Task ReplyAsync(string command, byte[] payload, CancellationToken ct = default) => + WriteRawAsync(Frame(command, payload), byteByByte: false, ct); + + /// Writes raw bytes, optionally one byte per write (TCP fragmentation). + public async Task WriteRawAsync(byte[] bytes, bool byteByByte, CancellationToken ct = default) + { + if (!byteByByte) + { + await _stream.WriteAsync(bytes, ct).ConfigureAwait(false); + await _stream.FlushAsync(ct).ConfigureAwait(false); + return; + } + + foreach (var b in bytes) + { + await _stream.WriteAsync(new[] { b }, ct).ConfigureAwait(false); + await _stream.FlushAsync(ct).ConfigureAwait(false); + await Task.Delay(1, ct).ConfigureAwait(false); + } + } + + /// Reads one raw byte (blocks until the peer sends or closes). + public async Task ReadByteAsync(CancellationToken ct) + { + var b = new byte[1]; + return await _stream.ReadAsync(b, ct).ConfigureAwait(false) == 0 ? -1 : b[0]; + } + + /// Closes the connection. + public void Close() => client.Dispose(); + + /// Builds a frame independently of the driver's encoder. + public static byte[] Frame(string command, byte[] payload) + { + var f = new byte[8 + payload.Length]; + BinaryPrimitives.WriteUInt32BigEndian(f, (uint)payload.Length); + Encoding.ASCII.GetBytes(command, f.AsSpan(4, 4)); + payload.CopyTo(f, 8); + return f; + } +} + +/// +/// In-process LSV2 peer on a loopback port. Either runs a per-connection (framing tests) +/// or serves a model. Records every request it receives on any connection, +/// so a test can prove that only allowlisted telegrams ever left the driver. +/// +public sealed class Lsv2Simulator : IAsyncDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(); + private readonly Func _script; + private readonly ConcurrentQueue _requests = new(); + private readonly ConcurrentBag _clients = new(); + private readonly Task _accept; + private readonly ConcurrentQueue _faults = new(); + private volatile bool _offline; + private int _connections; + + private Lsv2Simulator(Func script) + { + _script = script; + _listener.Start(); + _accept = Task.Run(AcceptLoopAsync); + } + + /// A simulator running a raw per-connection script. + public static Lsv2Simulator WithScript(Func script) => new(script); + + /// A simulator serving a control model (one model shared by all connections). + public static Lsv2Simulator WithControl(SimulatedControl control) + { + Lsv2Simulator? sim = null; + sim = new Lsv2Simulator(async (c, ct) => + { + while (true) + { + var req = await c.ReadRequestAsync(ct).ConfigureAwait(false); + if (req is null) return; + sim!._requests.Enqueue(req); + var reply = control.Handle(req); + switch (reply.Kind) + { + case SimReplyKind.Frame: + await c.ReplyAsync(reply.Command!, reply.Payload!, ct).ConfigureAwait(false); + break; + case SimReplyKind.Drop: + c.Close(); + return; + case SimReplyKind.Stall: + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return; + } + } + }); + return sim; + } + + /// The loopback port. + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + /// Every request received (model mode), in arrival order. + public IReadOnlyList Requests => _requests.ToArray(); + + /// Accepted connection count. + public int Connections => Volatile.Read(ref _connections); + + /// Exceptions a script threw (tests assert this is empty). + public IReadOnlyList Faults => _faults.ToArray(); + + /// Simulates a network outage: drops every live connection and refuses (closes) new ones. + public void GoOffline() + { + _offline = true; + foreach (var c in _clients) c.Dispose(); + } + + /// Ends the outage. + public void GoOnline() => _offline = false; + + private async Task AcceptLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); } + catch { return; } + if (_offline) + { + client.Dispose(); + continue; + } + + Interlocked.Increment(ref _connections); + _clients.Add(client); + _ = Task.Run(async () => + { + try { await _script(new SimConnection(client), _cts.Token).ConfigureAwait(false); } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or SocketException or EndOfStreamException) { } + catch (Exception ex) { _faults.Enqueue(ex); } + finally { client.Dispose(); } + }); + } + } + + /// + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync().ConfigureAwait(false); + _listener.Stop(); + foreach (var c in _clients) c.Dispose(); + try { await _accept.ConfigureAwait(false); } catch { /* stopped */ } + _cts.Dispose(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs new file mode 100644 index 000000000..6c9ce06ce --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs @@ -0,0 +1,121 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +public enum SimReplyKind { Frame, Drop, Stall } + +/// What the simulated control does with one request. +public sealed record SimReply(SimReplyKind Kind, string? Command = null, byte[]? Payload = null) +{ + public static SimReply Ok(string command, byte[] payload) => new(SimReplyKind.Frame, command, payload); + public static SimReply Refuse(int code, int type = 1) => new(SimReplyKind.Frame, "T_ER", [(byte)type, (byte)code]); + public static SimReply Drop() => new(SimReplyKind.Drop); + public static SimReply Stall() => new(SimReplyKind.Stall); + + /// Parses a reference fixture value S_RI:0004 / T_ER:0117. + public static SimReply FromFixture(string value) + { + var parts = value.Split(':', 2); + return Ok(parts[0], Convert.FromHexString(parts[1])); + } +} + +/// +/// A small, mutable model of an iTNC 530 speaking the six allowlisted LSV2 requests. Anything else is +/// recorded as a violation and refused — the driver must never produce one. +/// +public sealed class SimulatedControl +{ + private readonly ConcurrentQueue _violations = new(); + + public byte[] Version { get; set; } = Encoding.Latin1.GetBytes("iTNC 530\0NC 340490 04\0PLC\0OPT\0"); + public byte[]? Layout { get; set; } = LayoutBytes(10); + public Func? Login { get; set; } + public ConcurrentDictionary> Runtime { get; } = new(); + public ConcurrentDictionary DataPaths { get; } = new(StringComparer.Ordinal); + public Func? Memory { get; set; } + public List<(string Role, string Password)> Logins { get; } = []; + + /// Requests the control refused as not-allowlisted (must stay empty). + public IReadOnlyList Violations => _violations.ToArray(); + + /// Optional hook to override any reply (fault injection). + public Func? Intercept { get; set; } + + public SimReply Handle(Lsv2Request req) + { + if (Intercept?.Invoke(req) is { } forced) return forced; + switch (req.Command) + { + case "A_LG": + { + var fields = req.Text.Split('\0'); + var role = fields[0]; + var password = fields.Length > 2 ? fields[1] : ""; + lock (Logins) Logins.Add((role, password)); + return Login?.Invoke(role, password) ?? SimReply.Ok("T_OK", []); + } + case "R_VR" when req.Payload.Length == 0: + return SimReply.Ok("S_VR", Version); + case "R_PR" when req.Payload.Length == 0: + return Layout is null ? SimReply.Refuse(24) : SimReply.Ok("S_PR", Layout); + case "R_RI" when req.Payload.Length == 2: + return Runtime.TryGetValue(req.Selector, out var f) ? f() : SimReply.Refuse(24); + case "R_MB" when req.Payload.Length == 5: + var address = BinaryPrimitives.ReadUInt32BigEndian(req.Payload); + return Memory?.Invoke(address, req.Payload[4]) ?? SimReply.Refuse(23); + case "R_DP" when req.Payload.Length > 5: + { + var path = Encoding.Latin1.GetString(req.Payload, 4, req.Payload.Length - 5).Replace('\\', '/'); + return DataPaths.TryGetValue(path, out var r) ? r : SimReply.Refuse(24); + } + default: + _violations.Enqueue(req.Command); + return SimReply.Refuse(22); + } + } + + /// The reference test layout: words at 1000 with the given byte count, strings at 2000 (width 16), IW 3000, OW 4000. + public static byte[] LayoutBytes(int wordBytes) + { + var b = new byte[120]; + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(40), 1000); + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(44), wordBytes); + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(48), 2000); + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(52), 10); + b[56] = 16; + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(64), 3000); + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(68), 10); + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(72), 4000); + BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(76), 10); + return b; + } + + /// Two-byte uint16be runtime reply. + public static SimReply U16(int v) + { + var p = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(p, (ushort)v); + return SimReply.Ok("S_RI", p); + } +} + +/// Loads the ported Java .properties golden vectors. +public static class Fixtures +{ + public static IReadOnlyDictionary Load(string name) + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", name); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var line in File.ReadAllLines(path)) + { + if (line.Length == 0 || line[0] == '#') continue; + var eq = line.IndexOf('='); + map[line[..eq]] = line[(eq + 1)..]; + } + + return map; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/TestTags.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/TestTags.cs new file mode 100644 index 000000000..90bcae6d4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/TestTags.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +internal static class TestTags +{ + /// A TagConfig blob for an address (array flag and dataType filled in the way the editor writes them). + public static string Config(string address, string? dataType = null) + { + var o = new Dictionary { ["address"] = address }; + if (dataType is not null) o["dataType"] = dataType; + if (HeidenhainCatalog.TryGet(address, out var e) && e.IsArray) o["isArray"] = true; + return JsonSerializer.Serialize(o); + } + + public static HeidenhainTagDefinition Def(string address, string? dataType = null, string? rawPath = null) + { + if (!HeidenhainTagDefinitionFactory.TryFromTagConfig(rawPath ?? "Plant/H/dev1/" + address, "dev1", Config(address, dataType), out var d, out var error)) + throw new InvalidOperationException(error); + return d; + } + + public static RawTagEntry Raw(string address, string? dataType = null, string device = "dev1") => + new($"Plant/H/{device}/{address}", Config(address, dataType), false, device); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.csproj new file mode 100644 index 000000000..020d9cd29 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.csproj @@ -0,0 +1,40 @@ + + + + + $(NoWarn);OTOPCUA0001 + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + PreserveNewest + + +