From d50ee809e32d1fcc700bd05bab0680f16ffe6739 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:08:30 -0500 Subject: [PATCH 1/8] 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 + + + From 2b45694f91a71b44f0af834a58ec97fc009247b4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:10:36 -0500 Subject: [PATCH 2/8] feat(heidenhain): read-only, credential and endpoint deploy gates + TagConfig inspector (#803) - HeidenhainDeployChecks (wired into DraftValidator): HeidenhainTagWritable refuses a ReadWrite access level on any Heidenhain tag, so no node is ever advertised writable; HeidenhainCredentialPersisted refuses a literal password in DriverConfig/DeviceConfig (only blank / secret: / env: references; the value is never echoed); HeidenhainConfigInvalid for out-of-range settings or a tagged device without an endpoint. - EquipmentTagConfigInspector maps Heidenhain to HeidenhainTagDefinitionFactory.Inspect. - Driver validates only devices that carry tags (an unconfigured default Device1 no longer faults the driver). --- .../Validation/DraftValidator.cs | 1 + .../Validation/HeidenhainDeployChecks.cs | 92 +++++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Configuration.csproj | 2 + .../HeidenhainDriver.cs | 19 ++-- .../EquipmentTagConfigInspector.cs | 1 + .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 1 + .../DraftValidatorHeidenhainTests.cs | 78 ++++++++++++++++ 7 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 88714a5fd..5f9ed4f6e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -46,6 +46,7 @@ public static class DraftValidator errors.AddRange(SqlTagDeployChecks.UndeclaredQueryErrors(draft)); errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft)); ValidateMTConnectSingleDevice(draft, errors); + errors.AddRange(HeidenhainDeployChecks.Errors(draft)); ValidateNodeAclScopes(draft, errors); return errors; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs new file mode 100644 index 000000000..0518155e8 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs @@ -0,0 +1,92 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +/// +/// Heidenhain LSV2 deploy gates (lmxopcua#803). All reuse the driver's own Contracts parser, so the gate and +/// the runtime cannot disagree. +/// +/// +/// +/// — the driver is read-only (no IWritable), so a ReadWrite +/// access level would advertise a writable OPC node that can never be written. Refused, so no Heidenhain +/// node is ever writable. +/// — a password field that is not blank / secret: / +/// env: would store a controller password in the config database (replicated to every node). The +/// message never echoes the value. +/// — out-of-range driver settings, or an invalid endpoint on a device +/// that carries tags (the driver would fail to start). +/// +/// +public static class HeidenhainDeployChecks +{ + /// A Heidenhain tag authored ReadWrite. + public const string WritableCode = "HeidenhainTagWritable"; + + /// A literal password in a Heidenhain DriverConfig/DeviceConfig. + public const string CredentialCode = "HeidenhainCredentialPersisted"; + + /// Invalid Heidenhain settings or endpoint. + public const string ConfigCode = "HeidenhainConfigInvalid"; + + /// Runs every Heidenhain gate. + /// The draft. + /// The errors. + public static IReadOnlyList Errors(DraftSnapshot draft) + { + var errors = new List(); + foreach (var driver in draft.DriverInstances) + { + if (!string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.Ordinal)) continue; + var devices = draft.Devices + .Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)) + .ToList(); + var deviceIds = devices.Select(d => d.DeviceId).ToHashSet(StringComparer.Ordinal); + var tags = draft.Tags.Where(t => t.DeviceId is not null && deviceIds.Contains(t.DeviceId)).ToList(); + + foreach (var tag in tags.Where(t => t.AccessLevel == TagAccessLevel.ReadWrite)) + { + errors.Add(new(WritableCode, + $"Tag '{tag.Name}' on Heidenhain driver '{driver.Name}' is ReadWrite. The Heidenhain LSV2 driver is " + + "read-only (only logins and read requests are ever sent); set the tag's access level to Read.", + tag.TagId)); + } + + HeidenhainDriverOptions options; + try + { + var merged = DriverDeviceConfigMerger.Merge( + driver.DriverConfig, + devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(), + []); + options = HeidenhainOptionsParser.Parse(merged); + } + catch (InvalidOperationException ex) + { + errors.Add(new(ConfigCode, $"Heidenhain driver '{driver.Name}': {ex.Message}", driver.DriverInstanceId)); + continue; + } + + foreach (var problem in HeidenhainOptionsParser.Validate(options)) + { + var code = problem.Contains("password", StringComparison.OrdinalIgnoreCase) ? CredentialCode : ConfigCode; + errors.Add(new(code, $"Heidenhain driver '{driver.Name}': {problem}", driver.DriverInstanceId)); + } + + foreach (var device in devices) + { + if (!tags.Any(t => string.Equals(t.DeviceId, device.DeviceId, StringComparison.Ordinal))) continue; + var opts = options.Devices.FirstOrDefault(o => string.Equals(o.DeviceName, device.Name, StringComparison.Ordinal)); + var problems = opts is null ? ["Device has no endpoint."] : HeidenhainOptionsParser.ValidateDevice(opts); + foreach (var p in problems) + errors.Add(new(ConfigCode, $"Heidenhain device '{device.Name}': {p}", device.DeviceId)); + } + } + + return errors; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj index e153ea358..3d1bf2deb 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj @@ -47,6 +47,8 @@ + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index f00f58e59..50b847df0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -134,16 +134,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi { 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 d in devices) perDevice[d] = []; foreach (var raw in _options.RawTags) { @@ -171,6 +163,15 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi list.Add(def); } + // A control is validated only when it carries tags: a freshly created /raw Device has an empty + // DeviceConfig, and must not fault the whole driver (or the deploy) before it is configured. + var deviceErrors = perDevice.Where(kv => kv.Value.Count > 0) + .Select(kv => (kv.Key, Errors: HeidenhainOptionsParser.ValidateDevice(kv.Key))) + .Where(x => x.Errors.Count > 0) + .Select(x => $"Device '{x.Key.DeviceName ?? x.Key.Host}': {string.Join(" ", x.Errors)}") + .ToList(); + if (deviceErrors.Count > 0) throw new InvalidOperationException(string.Join("; ", deviceErrors)); + _skipped.Commit(); var runtimes = new List(); var byPath = new Dictionary(StringComparer.Ordinal); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs index d49bbc219..947f27084 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs @@ -65,6 +65,7 @@ public static class EquipmentTagConfigInspector [DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect, [DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect, [DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect, + [DriverTypeNames.Heidenhain] = Driver.Heidenhain.HeidenhainTagDefinitionFactory.Inspect, }; /// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index a1b2f9ab8..33e268cb2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -57,6 +57,7 @@ used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. --> + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs new file mode 100644 index 000000000..a71303447 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs @@ -0,0 +1,78 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// lmxopcua#803: the Heidenhain read-only, credential and endpoint deploy gates. +[Trait("Category", "Unit")] +public sealed class DraftValidatorHeidenhainTests +{ + private static DriverInstance Driver(string config = "{}", string type = "Heidenhain") => new() + { + DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = type, DriverConfig = config, + }; + + private static Device Dev(string config = """{"Host":"10.0.0.5"}""") => new() + { + DeviceId = "d1", DriverInstanceId = "hh", Name = "dev1", DeviceConfig = config, + }; + + private static Tag T(TagAccessLevel access) => new() + { + TagId = "t1", DeviceId = "d1", Name = "ExecState", DataType = "Int32", AccessLevel = access, + TagConfig = """{"address":"Status/ExecutionState"}""", + }; + + private static IReadOnlyList Validate(DriverInstance d, Device dev, params Tag[] tags) => + DraftValidator.Validate(new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", DriverInstances = [d], Devices = [dev], Tags = tags, + }); + + [Fact] + public void A_read_only_tag_on_a_configured_control_is_valid() + => Validate(Driver(), Dev(), T(TagAccessLevel.Read)).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); + + [Fact] + public void A_ReadWrite_tag_is_refused_so_no_node_is_ever_writable() + { + var e = Validate(Driver(), Dev(), T(TagAccessLevel.ReadWrite)).Single(x => x.Code == HeidenhainDeployChecks.WritableCode); + e.Context.ShouldBe("t1"); + e.Message.ShouldContain("read-only"); + } + + [Theory] + [InlineData("""{"Access":{"InspectPassword":"hunter2"}}""", "{\"Host\":\"10.0.0.5\"}")] + [InlineData("{}", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""")] + public void A_literal_password_is_refused_without_echoing_it(string driverConfig, string deviceConfig) + { + var errors = Validate(Driver(driverConfig), Dev(deviceConfig), T(TagAccessLevel.Read)); + errors.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode); + errors.ShouldAllBe(e => !e.Message.Contains("hunter2")); + } + + [Fact] + public void Secret_and_env_references_are_accepted() + => Validate(Driver("""{"Access":{"InspectPassword":"secret:lsv2/inspect","DncPassword":"env:LSV2_DNC"}}"""), Dev(), T(TagAccessLevel.Read)) + .ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); + + [Fact] + public void A_device_with_tags_but_no_endpoint_is_refused_while_an_empty_device_is_not() + { + Validate(Driver(), Dev("{}"), T(TagAccessLevel.Read)).ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode && e.Context == "d1"); + Validate(Driver(), Dev("{}")).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); + } + + [Fact] + public void Out_of_range_settings_are_refused() + => Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read)) + .ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode); + + [Fact] + public void Other_drivers_are_unaffected() + => Validate(Driver("""{"Access":{"InspectPassword":"x"}}""", type: "Modbus"), Dev(), T(TagAccessLevel.ReadWrite)) + .ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); +} From 3641d561541d17f9a479301fdf68a679f55eb42b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:17:26 -0500 Subject: [PATCH 3/8] feat(heidenhain): AdminUI driver/device forms, typed tag editor, CSV + browse-commit (#803) - HeidenhainDriverForm over HeidenhainDriverFormModel (JsonObject bag preserves Devices and unknown keys; no enum fields; Validate asks HeidenhainOptionsParser) + DriverConfigFormMap. - HeidenhainDeviceForm/Model (Host, Port 19000, optional per-device password references) + DeviceFormMap. Literal passwords are refused in both forms. - RawDriverTypeDialog entry; HeidenhainTagConfigEditor over HeidenhainTagConfigModel, whose Validate is the driver's own TryFromTagConfig; catalog leaves derive dataType + isArray; registered in TagConfigEditorMap + TagConfigValidator. - CsvColumnMap (HhAddress/HhDataType/HhIsArray) and RawBrowseCommitMapper branch. - Guards: DriverPageJsonConverterTests external-serializer entry, TagConfigNonEnumParityTests runtime map + corpus + freshly-authored rows, TagConfigRoundTrip, HeidenhainAuthoringTests. --- .../DeviceForms/HeidenhainDeviceForm.razor | 76 +++++++++ .../DeviceForms/HeidenhainDeviceModel.cs | 55 +++++++ .../Shared/Drivers/DriverFormMaps.cs | 2 + .../Drivers/Forms/HeidenhainDriverForm.razor | 152 ++++++++++++++++++ .../Forms/HeidenhainDriverFormModel.cs | 114 +++++++++++++ .../Shared/Raw/RawDriverTypeDialog.razor | 1 + .../HeidenhainTagConfigEditor.razor | 78 +++++++++ .../Uns/RawBrowseCommitMapper.cs | 4 + .../Uns/TagEditors/CsvColumnMap.cs | 9 ++ .../TagEditors/HeidenhainTagConfigModel.cs | 82 ++++++++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 1 + .../Uns/TagEditors/TagConfigValidator.cs | 1 + .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 1 + .../DriverPageJsonConverterTests.cs | 3 + .../Uns/HeidenhainAuthoringTests.cs | 122 ++++++++++++++ .../Uns/TagConfigNonEnumParityTests.cs | 19 +++ .../Uns/TagConfigRoundTrip.cs | 1 + 17 files changed, 721 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor new file mode 100644 index 000000000..ecaeade4b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor @@ -0,0 +1,76 @@ +@* Embeddable Heidenhain device (one LSV2 control) form body — authors Host/Port and optional per-device + password REFERENCES into the Device's DeviceConfig (PascalCase, matching HeidenhainDeviceOptions). Hosted by + DeviceModal. Literal passwords are refused here and at the deploy gate (HeidenhainCredentialPersisted). *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms + +
+
LSV2 endpoint
+
+
+
+ + +
Control hostname or IP. No default endpoint. Plain LSV2 is unencrypted — route it over an externally managed secure network.
+
+
+ + +
Default 19000.
+
+
+
+
+ +
+
Per-device credentials (optional overrides)
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
Blank = use the driver-level reference. secret:<name> resolves from the node's secret store, env:<VAR> from the environment, at each login. A literal password is never stored.
+ @if (_model.Validate() is { } error) + { +
@error
+ } +
+
+ +@code { + /// The device's DeviceConfig JSON. + [Parameter] public string DeviceConfigJson { get; set; } = "{}"; + /// Fired whenever the endpoint changes — enables @bind-DeviceConfigJson. + [Parameter] public EventCallback DeviceConfigJsonChanged { get; set; } + + private HeidenhainDeviceModel _model = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal)) + { + _model = HeidenhainDeviceModel.FromJson(DeviceConfigJson); + _lastParsed = DeviceConfigJson; + } + } + + /// Serializes the current endpoint to DeviceConfig JSON. + public string GetConfigJson() => _model.ToJson(); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DeviceConfigJsonChanged.InvokeAsync(json); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs new file mode 100644 index 000000000..fe6ae03d6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs @@ -0,0 +1,55 @@ +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; + +/// +/// The Heidenhain device (one control's endpoint) DeviceConfig, PascalCase keys matching +/// . Unknown keys are preserved. Password fields hold REFERENCES only +/// (blank / secret: / env:); uses the driver's own rule. +/// +public sealed class HeidenhainDeviceModel +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = HeidenhainDriverOptions.DefaultPort; + public string? InspectPassword { get; set; } + public string? DncPassword { get; set; } + public string? DataPassword { get; set; } + + private System.Text.Json.Nodes.JsonObject _bag = new(); + + public static HeidenhainDeviceModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new HeidenhainDeviceModel + { + Host = TagConfigJson.GetString(o, "Host") ?? "", + Port = TagConfigJson.GetInt(o, "Port", HeidenhainDriverOptions.DefaultPort), + InspectPassword = TagConfigJson.GetString(o, "InspectPassword"), + DncPassword = TagConfigJson.GetString(o, "DncPassword"), + DataPassword = TagConfigJson.GetString(o, "DataPassword"), + _bag = o, + }; + } + + public string ToJson() + { + TagConfigJson.Set(_bag, "Host", Host.Trim()); + TagConfigJson.Set(_bag, "Port", Port); + TagConfigJson.Set(_bag, "InspectPassword", Blank(InspectPassword)); + TagConfigJson.Set(_bag, "DncPassword", Blank(DncPassword)); + TagConfigJson.Set(_bag, "DataPassword", Blank(DataPassword)); + return TagConfigJson.Serialize(_bag); + } + + public string? Validate() + { + var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList(); + foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) }) + if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v))) + errors.Add($"{name} password must be blank, secret: or env: — never a literal."); + return errors.Count == 0 ? null : string.Join(" ", errors); + } + + private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs index 3357f68d5..13ff83b78 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs @@ -31,6 +31,7 @@ public static class DriverConfigFormMap [DriverTypeNames.Sql] = typeof(SqlDriverForm), [DriverTypeNames.Mqtt] = typeof(MqttDriverForm), [DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm), + [DriverTypeNames.Heidenhain] = typeof(HeidenhainDriverForm), [DriverTypeNames.Calculation] = typeof(CalculationDriverForm), }; @@ -67,6 +68,7 @@ public static class DeviceFormMap [DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm), [DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm), [DriverTypeNames.Mqtt] = typeof(MqttDeviceForm), + [DriverTypeNames.Heidenhain] = typeof(HeidenhainDeviceForm), }; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor new file mode 100644 index 000000000..6c90e3753 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor @@ -0,0 +1,152 @@ +@* Embeddable Heidenhain LSV2 driver config form body (lmxopcua#803). The per-control endpoint (Host/Port) + lives on HeidenhainDeviceForm (one /raw Device per control). The driver is READ-ONLY: there is no write + setting to author — the outbound LSV2 allowlist admits only logins and read requests. A thin shell over + HeidenhainDriverFormModel, which owns every rule and preserves keys it does not own (Devices). *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers + +
+
Connection
+
+
+
+ + +
100–60000. Bounds connect and each complete response. Default 5000.
+
+
+ + +
+
+ + +
Backoff doubles from the initial delay up to this ceiling.
+
+
+ + +
+
+
+
+ +
+
Access (read-only roles)
+
+
+
+
+ + +
+
Execution/program state, program, overrides, axes, tool, alarms. Off = no runtime-info requests.
+
+
+
+ + +
+
A refused DNC login never blocks reads the control permits under INSPECT.
+
+
+
+ + +
+
Opt-in DATA login, only when a DATA: binding needs it (reference-derived; iTNC only).
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
References only: blank, secret:<name> (node secret store) or env:<VAR>. Resolved at each login; a literal password is refused.
+
+
+ +
+
Polling & limits
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
Selectors 30–34. Units/semantics unverified on real controls — confirm before using as durations.
+
+
+
Intervals 100–3600000 ms. OPC reads and subscriptions are served from the cache; sampling cannot exceed these cadences.
+ @if (_m.Validate() is { } error) + { +
@error
+ } +
+
+ + + +@code { + /// The driver-level DriverConfig JSON. + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private HeidenhainDriverFormModel _m = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + _m = HeidenhainDriverFormModel.FromJson(DriverConfigJson); + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current config. + public string GetConfigJson() => _m.ToJson(); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs new file mode 100644 index 000000000..a5717b33b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; + +/// +/// Flat, bindable model of the Heidenhain driver-level DriverConfig. Round-trips through a JSON bag so every key +/// the form does not own (the multi-device Devices collection, future keys) survives a load→save. Every +/// field is an int/bool/string — there is no enum to serialize numerically — and asks the +/// driver's own , so the form accepts exactly what the factory accepts. +/// +public sealed class HeidenhainDriverFormModel +{ + public int TimeoutMs { get; set; } = 5000; + public int ProbeTimeoutSeconds { get; set; } = 10; + public bool TelemetryEnabled { get; set; } = true; + public bool DncLoginEnabled { get; set; } = true; + public bool DataEnabled { get; set; } + public string? InspectPassword { get; set; } + public string? DncPassword { get; set; } + public string? DataPassword { get; set; } + public int StatusMs { get; set; } = 1000; + public int CustomMs { get; set; } = 1000; + public int SlowMs { get; set; } = 10000; + public int MaxAlarms { get; set; } = 64; + public bool TimersEnabled { get; set; } + public int ReconnectInitialMs { get; set; } = 1000; + public int ReconnectMaxMs { get; set; } = 30000; + + /// + /// The one serializer this form writes config through. The document carries no enum today; the string-enum + /// converter is registered anyway so a future enum field can never serialize as a number (the repo-wide + /// form/factory trap) — guarded by DriverPageJsonConverterTests. + /// + public static readonly System.Text.Json.JsonSerializerOptions JsonOptions = new() + { + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + private JsonObject _bag = new(); + + public static HeidenhainDriverFormModel FromJson(string? json) + { + JsonObject bag; + try { bag = JsonNode.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json) as JsonObject ?? new JsonObject(); } + catch (System.Text.Json.JsonException) { bag = new JsonObject(); } + HeidenhainDriverOptions o; + try { o = HeidenhainOptionsParser.Parse(bag.ToJsonString()); } + catch (InvalidOperationException) { o = new HeidenhainDriverOptions(); } + return new HeidenhainDriverFormModel + { + TimeoutMs = o.TimeoutMs, + ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + TelemetryEnabled = o.Access.TelemetryEnabled, + DncLoginEnabled = o.Access.DncLoginEnabled, + DataEnabled = o.Access.DataEnabled, + InspectPassword = o.Access.InspectPassword, + DncPassword = o.Access.DncPassword, + DataPassword = o.Access.DataPassword, + StatusMs = o.Polling.StatusMs, + CustomMs = o.Polling.CustomMs, + SlowMs = o.Polling.SlowMs, + MaxAlarms = o.Polling.MaxAlarms, + TimersEnabled = o.Polling.TimersEnabled, + ReconnectInitialMs = o.Reconnect.InitialDelayMs, + ReconnectMaxMs = o.Reconnect.MaxDelayMs, + _bag = bag, + }; + } + + public string ToJson() + { + // Remove any case-variant of the keys this form owns so the bag never carries two spellings. + foreach (var k in new[] { "TimeoutMs", "ProbeTimeoutSeconds", "Access", "Polling", "Reconnect" }) + foreach (var existing in _bag.Select(p => p.Key).Where(p => string.Equals(p, k, StringComparison.OrdinalIgnoreCase)).ToList()) + _bag.Remove(existing); + _bag["TimeoutMs"] = TimeoutMs; + _bag["ProbeTimeoutSeconds"] = ProbeTimeoutSeconds; + var access = new JsonObject + { + ["TelemetryEnabled"] = TelemetryEnabled, + ["DncLoginEnabled"] = DncLoginEnabled, + ["DataEnabled"] = DataEnabled, + }; + if (Blank(InspectPassword) is { } i) access["InspectPassword"] = i; + if (Blank(DncPassword) is { } d) access["DncPassword"] = d; + if (Blank(DataPassword) is { } a) access["DataPassword"] = a; + _bag["Access"] = access; + _bag["Polling"] = new JsonObject + { + ["StatusMs"] = StatusMs, + ["CustomMs"] = CustomMs, + ["SlowMs"] = SlowMs, + ["MaxAlarms"] = MaxAlarms, + ["TimersEnabled"] = TimersEnabled, + }; + _bag["Reconnect"] = new JsonObject { ["InitialDelayMs"] = ReconnectInitialMs, ["MaxDelayMs"] = ReconnectMaxMs }; + return _bag.ToJsonString(JsonOptions); + } + + /// The driver's own verdict on this config (device endpoints are validated on the device form). + public string? Validate() + { + var o = HeidenhainOptionsParser.Parse(ToJson()); + var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions + { + TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access, + Polling = o.Polling, Reconnect = o.Reconnect, + }); + return errors.Count == 0 ? null : string.Join(" ", errors); + } + + private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index c71705441..282923123 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -73,6 +73,7 @@ ("Sql", DriverTypeNames.Sql), ("Calculation", DriverTypeNames.Calculation), ("MTConnect", DriverTypeNames.MTConnect), + ("Heidenhain", DriverTypeNames.Heidenhain), ]; private string _type = DriverTypeNames.Modbus; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor new file mode 100644 index 000000000..a5a44cb6c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor @@ -0,0 +1,78 @@ +@* Typed tag-config editor for a Heidenhain LSV2 tag (lmxopcua#803). A thin shell over HeidenhainTagConfigModel — + every rule lives there (and, through it, in the driver's HeidenhainTagDefinitionFactory). + + The driver is READ-ONLY: there is no Writable toggle, and the deploy gate refuses a ReadWrite access level. + Fixed leaves take the catalog's data type and array rank; only PLC:/DATA: bindings choose a type. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain + +
+
+ + + + @foreach (var e in HeidenhainCatalog.Entries) { } + +
+ A fixed leaf, PLC:AREA:INDEX (element index into the control's advertised region; AREA one of + @string.Join(", ", HeidenhainAddress.AreaTokens)) or DATA:/path (needs DATA access enabled on the driver). +
+
+
+ + @if (_m.IsCatalogAddress) + { + +
Fixed by the namespace catalog.
+ } + else + { + +
Must hold the area's native range (no narrowing): Boolean for bit areas, String for STRING.
+ } +
+
+ +@if (_m.Validate() is { } error) +{ +
@error
+} + +@code { + /// The tag's raw TagConfig JSON (supports @bind-ConfigJson). + [Parameter] public string? ConfigJson { get; set; } + /// Two-way config callback. + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — supplied by the hosting modal. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON. + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; + + private HeidenhainTagConfigModel _m = new(); + private string? _lastConfigJson; + + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = HeidenhainTagConfigModel.FromJson(ConfigJson); + } + + private string CatalogShape() => + HeidenhainCatalog.TryGet(_m.Address.Trim(), out var e) ? (e.IsArray ? $"{e.DataType}[]" : e.DataType.ToString()) : ""; + + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs index befe79e4a..d396d5554 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs @@ -280,6 +280,10 @@ public static class RawBrowseCommitMapper }.ToJson(); if (Is(driverType, DriverTypeNames.Galaxy)) return WriteSingleKey("attributeRef", address); + // Heidenhain: a browsed leaf is a fixed catalog address; the typed model derives its data type and array + // flag from the catalog, so the committed tag opens in the editor and passes the driver's gate unchanged. + if (Is(driverType, DriverTypeNames.Heidenhain)) + return new HeidenhainTagConfigModel { Address = address }.ToJson(); // Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel // needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields. // Without this branch a Sql commit fell through to the generic "address" key below, which the typed diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs index e58c8f7fb..6d7c8bd3d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -635,6 +635,15 @@ public static class CsvColumnMap new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String), new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String), }), + + // Heidenhain (#803): address + the binding data type NAME + the array flag the catalog's array leaves + // require. The driver's HeidenhainTagDefinitionFactory is the gate a CSV row is validated against. + new RawKeyCsvDriverTagMap(DriverTypeNames.Heidenhain, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("HhAddress", "address", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("HhDataType", "dataType", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("HhIsArray", "isArray", null), CsvRawKeyKind.Bool), + }), }; return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs new file mode 100644 index 000000000..ace423ce3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HeidenhainTagConfigModel.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Typed model of a Heidenhain raw tag's TagConfig (lmxopcua#803). Keys: address, dataType +/// (a NAME), isArray. Unknown keys (historian intent, …) are preserved. +/// +/// +/// The driver is the only authority. asks +/// about the blob this model would save (or, on +/// first load, the blob it was loaded from — CSV import and the API save verbatim), so the editor, the deploy +/// gate and the runtime accept exactly the same tags. +/// Shape is derived, not authored, for fixed leaves. For a catalog address +/// writes the catalog's data type and its array flag, so the node's rank always matches what the driver +/// publishes. A PLC/DATA binding carries the operator-chosen type. A stray writable key is dropped — +/// the driver is read-only. +/// +public sealed class HeidenhainTagConfigModel +{ + /// The address: a catalog leaf, PLC:AREA:INDEX or DATA:/path. + public string Address { get; set; } = ""; + + /// The binding data type (ignored for fixed leaves, whose type is the catalog's). + public DriverDataType DataType { get; set; } = DriverDataType.Int32; + + private JsonObject _bag = new(); + private string? _source; + + /// True when is a fixed catalog leaf. + public bool IsCatalogAddress => HeidenhainCatalog.TryGet(Address.Trim(), out _); + + public static HeidenhainTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + var address = TagConfigJson.GetString(o, "address") ?? ""; + var typeText = TagConfigJson.GetString(o, "dataType"); + DriverDataType type; + if (HeidenhainCatalog.TryGet(address.Trim(), out var entry)) type = entry.DataType; + else if (typeText is not null && HeidenhainTagDefinitionFactory.TryParseDataTypeName(typeText, out var parsed)) type = parsed; + else type = DriverDataType.Int32; + return new HeidenhainTagConfigModel { Address = address, DataType = type, _bag = o, _source = json ?? "" }; + } + + public string ToJson() + { + var address = Address.Trim(); + TagConfigJson.Set(_bag, "address", address); + TagConfigJson.Set(_bag, "writable", null); + if (HeidenhainCatalog.TryGet(address, out var entry)) + { + TagConfigJson.Set(_bag, "dataType", entry.DataType); + TagConfigJson.Set(_bag, "isArray", entry.IsArray ? true : null); + } + else + { + TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, "isArray", null); + } + + return _source = TagConfigJson.Serialize(_bag); + } + + public string? Validate() + { + if (string.IsNullOrWhiteSpace(Address)) return "An address is required (e.g. Status/ExecutionState, PLC:WORD:4, DATA:/PLC/memory/W/8)."; + var blob = _source is null ? ToJsonPreview() : _source; + return HeidenhainTagDefinitionFactory.TryFromTagConfig(TagConfigParseGate.AuthoringRawPath, "", blob, out _, out var error) + ? null + : error; + } + + private string ToJsonPreview() + { + var saved = _source; + var json = ToJson(); + _source = saved; + return json; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index bd58ccf39..b7ba8f5d4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -25,6 +25,7 @@ public static class TagConfigEditorMap [DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor), [DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor), + [DriverTypeNames.Heidenhain] = typeof(Components.Shared.Uns.TagEditors.HeidenhainTagConfigEditor), }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index fb713cf64..adc197984 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -27,6 +27,7 @@ public static class TagConfigValidator [DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(), + [DriverTypeNames.Heidenhain] = j => HeidenhainTagConfigModel.FromJson(j).Validate(), }; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 9d241e927..91182ad8b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -52,6 +52,7 @@ + + + + From 8fb03d6a72fb91f184288788160c816e58412cbd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:23:25 -0500 Subject: [PATCH 5/8] docs(heidenhain): driver page, README/probe index rows, drivers rule note (#803) docs/drivers/Heidenhain.md: read-only boundary + allowlist table, transport, config, credential references, address grammar (fixed catalog, PLC element/byte-count rules, DATA types incl. FF Boolean), quality table, lifecycle/health/comms loss, probe, tests, hardware-gated items and deliberate exclusions. Also: InitializeAsync clears surviving subscription handles (the actor re-subscribes after every successful init, so a kept handle would double-publish). --- .claude/rules/drivers.md | 7 + docs/drivers/Heidenhain.md | 196 ++++++++++++++++++ docs/drivers/README.md | 4 + docs/drivers/TestConnectProbes.md | 2 + .../HeidenhainDriver.cs | 2 + 5 files changed, 211 insertions(+) create mode 100644 docs/drivers/Heidenhain.md diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index a90a8e34c..4efac5d67 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -301,6 +301,13 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa alarm SET changes (#641) — never on a reconnect of the same set; a replayed `/current` must never re-raise (un-ack). `AlarmEventArgs` gained optional `OpcUaSeverity` (exact Part 9 severity) and `QualityStatusCode`; a quality-only event goes out as `AlarmQualityUpdate` per node and never through the snapshot projection. +- **Heidenhain LSV2 (#803):** read-only at three layers — no `IWritable`, the `HeidenhainTagWritable` deploy gate, and + `Lsv2ReadOnlyPolicy` (payload-checked in `Lsv2Codec.Frame`, the only frame builder; logins + R_VR/R_PR/R_RI/R_MB/R_DP + only). Never widen the allowlist to work around a controller refusal. Init opens nothing (acquisition starts on the + first subscribe/read); reads are cache-only; a refusal/denial/decode error is per-tag quality and does NOT degrade + health — only a down session does (with `LastSuccessfulRead` frozen). Passwords are `secret:`/`env:` refs only + (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; + `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md new file mode 100644 index 000000000..a30864d84 --- /dev/null +++ b/docs/drivers/Heidenhain.md @@ -0,0 +1,196 @@ +# Heidenhain LSV2 driver (read-only) + +`DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) · +Gitea **#803**. + +A native, **read-only** Heidenhain LSV2 client over TCP (default port **19000**) for iTNC/TNC controls. No +Java/Python runtime and no vendor SDK (RemoTools/DNC) — a managed `System.Net.Sockets` client. It is a port of the +ignitionoee v0.20.3 Java driver (commit `d1dda0a5`, `modules/drivers/gateway/.../driver/heidenhain`, wire reference +`docs/reference/heidenhain-lsv2.md`), whose protocol layouts are in turn derived from pyLSV2 commit `97a3d691`. +**The Java validation evidence (four real iTNC 530 controls, 2026-11-03) is reference evidence, not proof of this +port** — see [Hardware-gated](#hardware-gated--not-yet-proven). + +## Read-only boundary + +Three independent layers, each sufficient on its own for its concern: + +1. **No write capability.** `HeidenhainDriver` does not implement `IWritable`; `DriverInstanceActor` refuses every + OPC write before it reaches the driver (`HeidenhainDriverEndToEndTests`). +2. **No writable nodes.** The deploy gate `HeidenhainTagWritable` refuses a `ReadWrite` access level on any tag + under a Heidenhain driver, so no node is ever advertised writable. A `"writable": true` TagConfig key is + rejected by the tag reader and dropped by the typed editor. +3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks + command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s + public surface is pinned by a reflection test) and no configuration knob that widens it. + +| Request | Permitted payload | Reply | +|---|---|---| +| `A_LG` | role ∈ {`INSPECT`, `DNC`, `DATA`} NUL, optional NUL-free Latin-1 password NUL | `T_OK` (empty) | +| `R_VR` | empty | `S_VR` | +| `R_PR` | empty | `S_PR` | +| `R_RI` | exactly a uint16be selector ∈ {22–28, 30–34, 51} | `S_RI` | +| `R_MB` | uint32be address + uint8 count, 1 ≤ count ≤ 247, no 32-bit wrap | `S_MB` | +| `R_DP` | four zero bytes + NUL-free path + NUL (≤ 247 bytes total) | `S_DP` | + +Everything else is refused before transmission — every `C_*` (file/PLC/parameter writes, buffer negotiation), +`A_LO`, file reads (`R_FL`, `R_DI`, …), key/scope operations, `PLCDEBUG`/`MONITOR`/`FILE` roles, response +mnemonics sent as requests. `Lsv2ReadOnlyPolicyTests.A_refused_request_never_reaches_the_socket` proves zero bytes +leave the process for a refused request, even through the internal exchange primitive. Socket close ends a +session (no logout command). **Do not broaden the allowlist to work around a controller refusal.** + +## Transport + +`uint32be payloadLength | ASCII command[4] | payload` — legacy buffer mode, 255-byte frame maximum, no `C_CC` +negotiation. One transaction at a time per control (a `SemaphoreSlim` in `Lsv2Client`, and a single acquisition +loop per device). Each transaction has **one monotonic deadline** (`TimeoutMs`) covering write + header + body, so a +trickling peer cannot extend it. EOF, deadline, an oversized (> 247) or unexpected reply, a malformed `T_ER`, or a +`T_OK` with a body **closes the client** — a later request can never consume leftover bytes. A complete +`T_ER`/`T_BD` refusal keeps the stream synchronised. `Close()` never waits for the transaction gate, so it always +interrupts a blocked read. + +## Configuration + +Driver config (`HeidenhainDriverOptions`, AdminUI **HeidenhainDriverForm**) — every field is int/bool/string (no +enums, so the numeric-enum form/factory trap cannot occur): + +| Key | Default | Range / meaning | +|---|---|---| +| `TimeoutMs` | 5000 | 100–60000; connect + per-transaction deadline | +| `Access.TelemetryEnabled` | true | Off = no `R_RI` requests at all | +| `Access.DncLoginEnabled` | true | Optional DNC login; a refusal never blocks reads permitted under INSPECT | +| `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists (iTNC only) | +| `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) | +| `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 | +| `Polling.MaxAlarms` | 64 | 1–256 | +| `Polling.TimersEnabled` | **false** | Experimental selectors 30–34 | +| `Reconnect.InitialDelayMs` / `MaxDelayMs` | 1000 / 30000 | doubling backoff, bounded | +| `ProbeTimeoutSeconds` | 10 | Test Connect, 1–60 | + +Device config (one `/raw` Device per control, AdminUI **HeidenhainDeviceForm**): `Host` (required, no default), +`Port` (19000), optional per-device `InspectPassword`/`DncPassword`/`DataPassword` overrides. A device is validated +only once it carries tags, so an unconfigured default `Device1` does not fault the driver. + +**Credentials.** A password field is blank, `secret:` (the node's encrypted secret store, via +`ISecretResolver`) or `env:`. It is resolved **at each login** and never held in options or session +state. A literal is refused by the parser, both AdminUI forms and the deploy gate `HeidenhainCredentialPersisted` +(the message never echoes the value). Fail-closed: an absent secret / unset variable fails the login rather than +sending the reference text. Plain LSV2 is unencrypted — reach the control over an externally managed secure +network; there is no SSH subsystem. + +## Addresses (TagConfig) + +`{"address": "
", "dataType": "", "isArray": true|false}` — parsed by +`HeidenhainTagDefinitionFactory`, the single authority shared by the driver, the deploy inspector +(`EquipmentTagConfigInspector`) and the AdminUI editor/validator. + +**Fixed namespace** (device-relative; browse-committable from the `/raw` picker without contacting the control): + +| Folder | Leaves (type) | Source | Group | +|---|---|---|---| +| `Controller` | `Model`, `NcVersion`, `PlcVersion`, `Options` (String) | `R_VR` (4 or 5 fields; SPLC skipped; empty options preserved) | Slow | +| `Status` | `ExecutionState` (Int32), `ProgramState` (Int32) — raw native enums | `R_RI 23`, `26` | Status | +| `Program` | `Main`, `Current` (String), `Line` (Int64) | `R_RI 24` | Status | +| `Overrides` | `Feed`, `Spindle`, `Rapid` (Float64, percent) | `R_RI 25` (uint32 hundredths) | Status | +| `Axes` | `Names` (String[]), `Positions` (Float64[]) — aligned, controller units | `R_RI 22` | Status | +| `Tool` | `Number` (Int64), `Index`, `AxisCode` (Int32), `Length`, `Radius` (Float64) | `R_RI 51` (8-byte reply ⇒ geometry `BadNotSupported`, never 0) | Slow | +| `Alarms` | `Classes`, `Groups`, `Numbers` (Int32[]), `Messages` (String[]), `Count` (Int32) | `R_RI 27` then `28`; only refusal 57 ends the list | Slow | +| `Timers` | `NcUptime`, `MachineUptime`, `MachineRuntime`, `Spindle0Runtime`, `Spindle1Runtime` (Int64, raw) | `R_RI 30–34`, **disabled by default** | Slow | +| `Capabilities` | one String per feature + `PlcLayout`, `DncLogin`, `DataLogin` | local, never a wire read | Slow | + +Array leaves require `"isArray": true` (variable length); the typed editor and browse commit write it. Capability +states: `Unknown`, `Supported`, `Disabled`, `AccessDenied`, `Unsupported`, `InvalidData`, `CommunicationLost` — +evidence about the last read, not a licence assertion. + +**PLC bindings** `PLC::` + `dataType`, read via `R_MB`. `INDEX` is an **element** index (1–10 digits, +≤ uint32), multiplied by the native width and added to the region base the control advertises in `R_PR`. +Numeric `R_PR` counts are **byte capacities** (element count = ⌊bytes / width⌋; BYTE/WORD/DWORD share one pool); +the next populated advertised base is a defensive cap. Bit and STRING counts are entries. + +| Area | Native | Allowed `dataType` | +|---|---|---| +| `MARKER`, `INPUT`, `OUTPUT`, `COUNTER`, `TIMER` | one byte per Boolean (0/1 only) | Boolean | +| `BYTE` / `WORD` / `DWORD` | signed int8 / int16le / int32le | Int16 (BYTE, WORD only), Int32, Int64 | +| `INPUT_WORD`, `OUTPUT_WORD` | uint16le | Int32, Int64 | +| `STRING` | advertised fixed width, terminated Latin-1 (too wide ⇒ error, never truncated) | String | + +Example: `PLC:WORD:4999` → `R_MB` at `base + 4999×2`. Note that `DATA:/PLC/memory/W/9998` uses a native **byte +offset** — the two address spaces differ (see the wire reference). + +**DATA bindings** `DATA:` + `dataType` ∈ Boolean/Int16/Int32/Int64/Float64/String, read via `R_DP` under the +opt-in DATA role (`/`→`\`, `"`→`'` normalisation; NUL-free Latin-1; ≤ 242 chars). Value types 2/3/5/8/11/16/17 are +decoded strictly; Boolean accepts `00` and `01`/**`FF`** (FF observed on real iTNC 530 I/O). Coercion never +truncates or wraps — otherwise `BadTypeMismatch`. + +At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`). + +## Quality + +| Condition | Value status / capability | +|---|---| +| Refusal 22/24 | `BadNotSupported` / `Unsupported` — suppressed for the session | +| Refusal 23/49 | `BadUserAccessDenied` / `AccessDenied` — suppressed for the session, except a provisional denial while the optional DNC login is temporarily failing (retried) | +| Other refusal | `BadUnexpectedError` / `Unknown` — retried next poll | +| Malformed complete record (length, terminator, NaN, duplicate axis, …) | `BadDecodingError` / `InvalidData` | +| Binding type mismatch | `BadTypeMismatch` | +| Out-of-region binding, alarm overflow | `BadOutOfRange` / `InvalidData` (never a truncated list) | +| Disabled feature (telemetry off, timers off, DATA off) | `BadNotSupported` / `Disabled` | +| Not yet acquired | `BadWaitingForInitialData` | +| Transport lost | **every** remote value of that control `BadNoCommunication` at once; capabilities `CommunicationLost` | + +There is no cross-poll Good cache: each batch reads the wire; within a batch a shared selector (program, +overrides, axes, tool, the whole alarm walk) and aliased custom addresses are read once. + +## Lifecycle, health and comms loss + +- **Construction and `InitializeAsync` open nothing.** Acquisition starts on the first `SubscribeAsync` / + `ReadAsync`, so the `/raw` browse (universal discovery browser, static catalog, `RediscoverPolicy = Once`) never + opens a session to a production control. +- One background loop per control: connect → INSPECT login → `R_VR` (required) → optional DNC/DATA logins → + `R_PR` when PLC bindings exist → poll the Status/Custom/Slow groups. Each established session is a new + **generation**; a batch returning after its session was retired can never overwrite the outage invalidation. +- **Reads are cache-only** (`ReadAsync` never touches the wire); subscriptions push on (value, **status**) change, + so a control returning with identical readings still re-publishes Good. +- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`). Unlike the Java base, an + initial connect failure also retries (no manual restart). +- **Health:** `Degraded` only while a control's session is down (connect failing / lost), with + `LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and + decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one + selector). The reference's poll-group "partial data" aggregate is on the `/hosts` diagnostics bag + (`.data` = `Good`/`Partial`/`Lost`, plus `.state` and `.model`). Re-initialise tears down every + session first (#630). + +## Test-Connect probe + +`HeidenhainDriverProbe` (registered on admin and driver nodes, with the node's `ISecretResolver`) opens a real +session to the first device: INSPECT login + `R_VR`. A bare TCP listener does not read as healthy. + +## Tests + +- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests` — in-process **LSV2 simulator** (`Simulator/`): a + scripted raw-socket peer for framing tests and a `SimulatedControl` model that records every request and flags + any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed + as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three). +- `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`. +- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet + parity suites. +- **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional + `HEIDENHAIN_LSV2_PORT`, `HEIDENHAIN_LSV2_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a + driver end-to-end status poll. Record its output as .NET commissioning evidence. + +## Hardware-gated — not yet proven + +No real control was available for this port. Still open: + +- **.NET commissioning evidence** on a real control (the LiveIntegration gate) — browse/types/arrays through a + real OPC UA client, OPC write refusal, network interruption + recovery quality, bounded traffic under load. +- Controller families/versions other than iTNC 530 (TNC 320/430/640) — unproven even in the Java reference. +- Successful raw **`R_MB`** decoding and its Boolean encoding (denied on all four tested controls). +- Physical units of axis positions / tool geometry vs. the HMI; experimental timer semantics (selectors 30–34). +- DNC/DATA permission matrices per control; sustained fleet/load operation. + +## Deliberate exclusions + +Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE +roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH, +PLC-wide symbol discovery / memory scanning, OEE mapping, and native alarms as Part 9 conditions (alarms are +published as typed arrays). diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 3dd5309fd..18d283159 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -73,6 +73,7 @@ Three properties worth knowing: | [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHostConnectivityProbe | Gateway/aggregation driver — does not proxy upstream history (no driver has a history capability). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI | | [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) | | [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange` — `IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific | +| [Heidenhain](Heidenhain.md) | `Driver.Heidenhain` (+ `.Contracts`) | A | Native managed LSV2 TCP client (port 19000) — no vendor SDK, no Java/Python | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDriverDiagnostics | **Read-only at three layers**: no `IWritable`, the `HeidenhainTagWritable` deploy gate, and a payload-aware outbound allowlist (logins + read requests only). Cached reads; all-value `BadNoCommunication` on comms loss; passwords are `secret:`/`env:` references only (#803) | | [Sql](Sql.md) | `Driver.Sql` (+ `.Browser`, `.Contracts`, `.Dialect`) | A | ADO.NET behind an `ISqlDialect` seam — `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres` (Npgsql), `Oracle` (ODP.NET), `MySql` (MySqlConnector), `Odbc` (ANSI SQL:2008 profile only) | IDriver, ITagDiscovery, IReadable, IWritable (opt-in), ISubscribable, IHostConnectivityProbe | DB-poll driver (KeyValue / WideRow / named read-only `Query` tags) with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). Writes are off unless `allowWrites` + the tag's `writable`; a tag's `write` block picks Update / Insert / Upsert / stored Procedure (#781; Odbc: Update/Insert). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). | | [Calculation](Calculation.md) | `Driver.Calculation` | A | none — computes from other tags' RawPaths | IDriver, IDependencyConsumer, ISubscribable, IReadable | **Pseudo-driver**, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time `scriptId`-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). `runTimeoutMs` is authorable through `CalculationDriverForm` (the "no typed config form" gap — G-1 — was closed 2026-07-27) | | [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the default historian backend (the other is `Historian.SqlServer`, below). Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) | @@ -100,6 +101,9 @@ Three properties worth knowing: data-plane precedence rules, and a non-trivial known-limitations list need explaining up front: - [MTConnect.md](MTConnect.md) — Agent config, `FullName`==DataItem `id`, `UNAVAILABLE`→`BadNoCommunication`, CONDITION-as-`String`, browse-via-universal-browser, fixture recipe, deferred write-back +- **Heidenhain** (#803) — read-only LSV2 boundary, configuration, address grammar, quality, hardware-gated items: + - [Heidenhain.md](Heidenhain.md) + - **Historian.Gateway** and **Historian.SqlServer** (server-side historian backends, not tag drivers) are documented in the main guide: - [../Historian.md](../Historian.md) — backend selection (`ServerHistorian:Backend`), the SQL Server backend and HistoryUpdate, plus the HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).) diff --git a/docs/drivers/TestConnectProbes.md b/docs/drivers/TestConnectProbes.md index c9797412f..946303481 100644 --- a/docs/drivers/TestConnectProbes.md +++ b/docs/drivers/TestConnectProbes.md @@ -49,6 +49,7 @@ with a human-readable explanation rather than a false-green TCP-open tick. | **TwinCAT** | `AdsClient.Connect` + `ReadStateAsync`. See [degrade semantics](#twincat-degrade) below. | `"ADS state: {state}"` | Deferred — no ADS target | | **FOCAS** | `cnc_allclibhndl3` via a direct `DllImport("fwlib32")` in the probe. See [degrade semantics](#focas-degrade) below. | `"FOCAS handle OK"` | Deferred — no CNC + FWLIB | | **Galaxy** | gRPC unary call to `GalaxyRepository.TestConnection` on the configured mxaccessgw endpoint. See [auth-rejection rule](#galaxy-auth-rejection) below. | `"gateway gRPC OK"` | `http://10.100.0.48:5120` (mxaccessgw) | +| **Heidenhain** | LSV2 `A_LG INSPECT` (password resolved from its `secret:`/`env:` reference) + `R_VR`, through the same read-only allowlist as the driver; a refusal or malformed version record → fail. No logout — the socket close ends the session. | `"LSV2 INSPECT session OK at host:port: NC "` | Deferred — no LSV2 control; simulator-proven | | **MQTT** | One MQTT `CONNECT` (no SUBSCRIBE, no publish) under a `-probe-`-suffixed client id, then disconnect. **A rejected CONNACK is a returned result code, not an exception** — MQTTnet 5 does not throw — so the probe inspects `ResultCode` and reuses the driver's own `DescribeConnackRejection`, making the button and the running driver word a rejection identically. | `"MQTT CONNECT OK"` | `10.100.0.35:8883` (Mosquitto TLS+auth fixture) | **Historian.Wonderware** had a TCP `Hello`→`HelloAck` handshake probe before Phase 5, but the @@ -138,6 +139,7 @@ above. (Live verification on `10.100.0.48:5120` with no key returns | AbLegacy | Deferred | No PLC5/SLC sim; unit-proven + code path identical to AbCip | | TwinCAT | Deferred | No ADS target; unit-proven + degrade guard tested | | FOCAS | Deferred | No CNC + FWLIB on dev host; degrade guard is the CI-observable path | +| Heidenhain | Deferred | No LSV2 control; probe path shared with the simulator-tested driver (#803) | --- diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index 50b847df0..bf95afcc5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -104,6 +104,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi { // #630: release prior device state first — a re-init must never leak a session per attempt. await StopDevicesAsync().ConfigureAwait(false); + // The actor re-subscribes after every successful init; a surviving handle would double-publish. + _subscriptions.Clear(); lock (_lifecycleLock) _state = DriverState.Initializing; try { From f7a3588b24f2dcd42b9f7ab79ab52b46bba0fd9c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:49:48 -0500 Subject: [PATCH 6/8] fix(heidenhain): namespaced credentials, optional-role credential isolation, loop robustness (#803 review) - Credential exfiltration (review 2): password references are scoped to secret:heidenhain/ and env:OTOPCUA_HEIDENHAIN_ only. Enforced by HeidenhainOptionsParser.TryParseCredentialReference (parser, probe, deploy gate) AND again at resolve time in HeidenhainCredentials (fail closed without consulting the store or the environment), so Test Connect can no longer send env:ConnectionStrings__ConfigDb or another subsystem's secret to an arbitrary Host. The probe also refuses an unsendable password. - Optional-role credentials (review 6): an unresolvable/NUL/non-Latin-1/>247-byte DNC or DATA credential never sends a login and never fails the session; the reads needing that role are BadConfigurationError (capability ConfigurationError) and INSPECT telemetry continues. A policy refusal inside AcquireAsync is per-tag, not a session teardown. - Start after DisposeAsync is a no-op (review 3); initial subscription push and Publish are ordered under one lock (review 4); JSON-null sections are normalised and the acquisition loop catches every fault (review 5); backoff resets only after a session published a poll batch, with clamped delays (review 7). Tests: HeidenhainReviewFixTests + namespace/normalisation tests in HeidenhainConfigTests; each was confirmed red against the reverted fix. --- .../HeidenhainCatalog.cs | 1 + .../HeidenhainOptionsParser.cs | 198 +++++++++++++-- .../HeidenhainCredentials.cs | 51 ++-- .../HeidenhainDeviceRuntime.cs | 81 ++++-- .../HeidenhainDriver.cs | 40 +-- .../HeidenhainDriverProbe.cs | 10 +- .../HeidenhainSession.cs | 53 +++- .../HeidenhainStatus.cs | 9 +- .../HeidenhainConfigTests.cs | 116 +++++++-- .../HeidenhainDriverTests.cs | 2 +- .../HeidenhainLiveIntegrationTests.cs | 6 +- .../HeidenhainReviewFixTests.cs | 236 ++++++++++++++++++ 12 files changed, 700 insertions(+), 103 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainReviewFixTests.cs 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 index 0c5717b40..5cef4a304 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs @@ -67,6 +67,7 @@ public static class HeidenhainCatalog public const string Unsupported = "Unsupported"; public const string InvalidData = "InvalidData"; public const string CommunicationLost = "CommunicationLost"; + public const string ConfigurationError = "ConfigurationError"; } /// Every fixed leaf, value leaves first then the capability strings. 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 index 22e80fa57..925b4735c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs @@ -4,8 +4,8 @@ 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. +/// the Test-Connect probe, the deploy gate and the AdminUI form/save path, so none of them can disagree about +/// what a config means. /// public static class HeidenhainOptionsParser { @@ -15,6 +15,37 @@ public static class HeidenhainOptionsParser /// Prefix of an environment-variable password reference. public const string EnvPrefix = "env:"; + /// + /// The ONLY secret-store namespace a Heidenhain password may name: secret:heidenhain/<name> (the + /// store's slash-separated, driver-scoped naming, as in galaxy/<inst>/apikey). Scoping is the + /// exfiltration guard: the reference is resolved on the node and sent in cleartext in A_LG to a Host + /// the author chose, so it must never be able to name another subsystem's secret. + /// + public const string SecretNamespace = "heidenhain/"; + + /// + /// The ONLY environment variables a Heidenhain password may name: env:OTOPCUA_HEIDENHAIN_<NAME> + /// (exact upper-case prefix; NAME is [A-Z0-9_]+) — never ConnectionStrings__*, + /// ServerHistorian__ApiKey or any other process variable. + /// + public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_"; + + /// The password fields a Heidenhain DriverConfig/DeviceConfig may carry. + public static readonly IReadOnlyList PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"]; + + /// The kind of a parsed credential reference. + public enum CredentialKind + { + /// Blank — no password. + None, + + /// secret:heidenhain/…. + Secret, + + /// env:OTOPCUA_HEIDENHAIN_…. + Environment, + } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -22,7 +53,10 @@ public static class HeidenhainOptionsParser AllowTrailingCommas = true, }; - /// Deserialises a config document. Throws on unreadable JSON. + /// + /// Deserialises a config document (null sections normalised to defaults). Throws + /// on unreadable JSON. + /// /// The DriverConfig JSON (blank ⇒ defaults). /// The options. public static HeidenhainDriverOptions Parse(string? json) @@ -30,8 +64,8 @@ public static class HeidenhainOptionsParser if (string.IsNullOrWhiteSpace(json)) return new HeidenhainDriverOptions(); try { - return JsonSerializer.Deserialize(json, JsonOptions) - ?? throw new InvalidOperationException("Heidenhain driver config deserialised to null."); + return Normalize(JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException("Heidenhain driver config deserialised to null.")); } catch (JsonException ex) { @@ -39,6 +73,30 @@ public static class HeidenhainOptionsParser } } + /// + /// Replaces JSON-null sections ("Access": null, "Polling": null, …) and null list entries + /// with their defaults, so nothing downstream can dereference a null section. + /// + /// The options as deserialised (or constructed). + /// An options instance with every section non-null. + public static HeidenhainDriverOptions Normalize(HeidenhainDriverOptions o) + { + ArgumentNullException.ThrowIfNull(o); + if (o.Access is not null && o.Polling is not null && o.Reconnect is not null && o.Devices is not null + && o.RawTags is not null && o.Devices.All(d => d is not null) && o.RawTags.All(t => t is not null)) + return o; + return new HeidenhainDriverOptions + { + Devices = (o.Devices ?? []).Where(d => d is not null).ToList(), + RawTags = (o.RawTags ?? []).Where(t => t is not null).ToList(), + TimeoutMs = o.TimeoutMs, + Access = o.Access ?? new HeidenhainAccessOptions(), + Polling = o.Polling ?? new HeidenhainPollingOptions(), + Reconnect = o.Reconnect ?? new HeidenhainReconnectOptions(), + ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + } + /// /// Validates driver-level ranges and every password reference. Device endpoints are validated per device /// by (a driver may be authored before its devices). @@ -48,22 +106,23 @@ public static class HeidenhainOptionsParser public static IReadOnlyList Validate(HeidenhainDriverOptions o) { ArgumentNullException.ThrowIfNull(o); + o = Normalize(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(); + var p = o.Polling; 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(); + var r = o.Reconnect; 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(); + var a = o.Access; CheckRef(errors, "Access.InspectPassword", a.InspectPassword); CheckRef(errors, "Access.DncPassword", a.DncPassword); CheckRef(errors, "Access.DataPassword", a.DataPassword); - foreach (var d in o.Devices ?? []) + foreach (var d in o.Devices) { var label = string.IsNullOrWhiteSpace(d.DeviceName) ? d.Host : d.DeviceName; CheckRef(errors, $"Device '{label}' InspectPassword", d.InspectPassword); @@ -88,24 +147,125 @@ public static class HeidenhainOptionsParser } /// - /// 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. + /// True when is an acceptable password field: blank, + /// secret:heidenhain/<name> or env:OTOPCUA_HEIDENHAIN_<NAME>. /// /// The field value. - /// Whether it is a reference (or blank). - public static bool IsCredentialReference(string? value) + /// Whether it is a namespaced reference (or blank). + public static bool IsCredentialReference(string? value) => TryParseCredentialReference(value, out _, out _); + + /// + /// Parses a credential reference, enforcing the Heidenhain namespace — the single rule the parser, the + /// resolver (again at resolve time), the probe, the AdminUI save path and the deploy gate all apply. + /// + /// The field value. + /// The reference kind. + /// The full secret name (incl. heidenhain/) or the environment variable name. + /// True for blank or a namespaced reference. + public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name) { + kind = CredentialKind.None; + name = ""; 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..]); + if (value.StartsWith(SecretPrefix, StringComparison.Ordinal)) + { + var secret = value[SecretPrefix.Length..]; + if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false; + var rest = secret[SecretNamespace.Length..]; + if (rest.Length is 0 or > 200) return false; + foreach (var c in rest) + if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false; + if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false; + kind = CredentialKind.Secret; + name = secret; + return true; + } + + if (value.StartsWith(EnvPrefix, StringComparison.Ordinal)) + { + var variable = value[EnvPrefix.Length..]; + if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false; + var rest = variable[EnvVariablePrefix.Length..]; + if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_')) + return false; + kind = CredentialKind.Environment; + name = variable; + return true; + } + return false; } + /// + /// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not + /// namespaced references — at the top level (a device / merged config), under Access, and under every + /// Devices[] entry; keys matched case-insensitively, as the parser binds them. The messages name the + /// field, never the value. Unreadable JSON is reported, since it cannot be checked. + /// + /// The config JSON. + /// The problems; empty when every password field is acceptable. + public static IReadOnlyList DescribeCredentialProblems(string? json) + { + var problems = new List(); + if (string.IsNullOrWhiteSpace(json)) return problems; + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + } + catch (JsonException) + { + problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked."); + return problems; + } + + using (doc) + { + if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems; + ScanObject(doc.RootElement, "", problems); + foreach (var p in doc.RootElement.EnumerateObject()) + { + if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object) + ScanObject(p.Value, "Access.", problems); + if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array) + { + var i = 0; + foreach (var d in p.Value.EnumerateArray()) + { + if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems); + i++; + } + } + } + } + + return problems; + } + + /// The one wording of the credential rule (never includes the value). + /// The field. + /// The message. + public static string CredentialRuleMessage(string field) => + $"{field} must be blank, 'secret:{SecretNamespace}' or 'env:{EnvVariablePrefix}' — a literal " + + "password, or a reference outside the Heidenhain namespace, is refused."; + + private static void ScanObject(JsonElement obj, string prefix, List problems) + { + foreach (var p in obj.EnumerateObject()) + { + if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue; + var ok = p.Value.ValueKind switch + { + JsonValueKind.Null => true, + JsonValueKind.String => IsCredentialReference(p.Value.GetString()), + _ => false, + }; + if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name)); + } + } + 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."); + if (!IsCredentialReference(value)) errors.Add(CredentialRuleMessage(field)); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs index 1685b3478..f441dfcb1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs @@ -2,10 +2,16 @@ using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; +/// A password reference that cannot be resolved (or may not be resolved). Never carries the value. +public sealed class HeidenhainCredentialException(string message) : InvalidOperationException(message); + /// /// 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. +/// state. Fail-closed AT RESOLVE TIME, independently of any earlier validation: only blank, +/// secret:heidenhain/… and env:OTOPCUA_HEIDENHAIN_… are resolved — anything else (a literal, another +/// subsystem's secret, ConnectionStrings__*), a secret: with no store, an absent secret or an unset +/// variable throws . The reference is resolved on this node and sent +/// in cleartext to the configured control, which is why the namespace is enforced here as well. /// internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func? environment = null) { @@ -14,12 +20,13 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, FuncThe 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 access = options.Access ?? new HeidenhainAccessOptions(); 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."), + "INSPECT" => device.InspectPassword ?? access.InspectPassword, + "DNC" => device.DncPassword ?? access.DncPassword, + "DATA" => device.DataPassword ?? access.DataPassword, + _ => throw new HeidenhainCredentialException("Unexpected LSV2 role."), }; return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false); } @@ -27,23 +34,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, FuncResolves 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)) + if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name)) + throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)"); + switch (kind) { - 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)."); + case HeidenhainOptionsParser.CredentialKind.None: + return ""; + case HeidenhainOptionsParser.CredentialKind.Environment: + return _environment(name) is { Length: > 0 } value + ? value + : throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed)."); + default: + if (resolver is null) + throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed)."); + var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); + return !string.IsNullOrEmpty(resolved) + ? resolved + : throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (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 index fe1003583..cdfe9dbed 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs @@ -66,6 +66,11 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private DateTime? _lastSuccessUtc; private string? _lastError; private int _consecutiveFailures; + private bool _disposed; + private volatile bool _pollProgress; + + /// Consecutive sessions that ended without one published poll batch (test/diagnostic). + internal int ConsecutiveFailures => Volatile.Read(ref _consecutiveFailures); public HeidenhainDeviceRuntime( HeidenhainDeviceOptions device, @@ -79,7 +84,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable ILogger logger) { _device = device; - _options = options; + _options = HeidenhainOptionsParser.Normalize(options); _tags = tags; _passwords = passwords; _publish = publish; @@ -134,7 +139,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable { lock (_lifecycle) { - if (_loop is not null) return; + // A Start racing (or following) DisposeAsync must never revive an orphan loop: the driver may be + // iterating a device list it captured just before re-init disposed it. + if (_disposed || _loop is not null) return; _cts = new CancellationTokenSource(); var token = _cts.Token; _loop = Task.Run(() => RunAsync(token), CancellationToken.None); @@ -143,9 +150,59 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private async Task RunAsync(CancellationToken ct) { + // Nothing may kill this loop silently: every iteration — connect, poll, retire, backoff — is guarded, + // and an unexpected fault becomes a logged retire + backoff, never an unobserved faulted task. while (!ct.IsCancellationRequested) + { + try + { + await RunOnceAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Heidenhain {Host} acquisition loop fault; retrying", HostName); + try { RetireAll("Acquisition loop fault: " + ex.GetType().Name); } + catch (Exception inner) { _logger.LogError(inner, "Heidenhain {Host} retire failed", HostName); } + } + + if (ct.IsCancellationRequested) break; + // Backoff resets only when the last session made real progress (published a poll batch): a control + // that accepts login + version and then drops must not be re-dialled every InitialDelayMs. + if (_pollProgress) _consecutiveFailures = 0; + _consecutiveFailures++; + try + { + SetState(HeidenhainConnectionState.Backoff, null); + await Task.Delay(BackoffDelay(_consecutiveFailures), _time, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } + catch (Exception ex) + { + _logger.LogError(ex, "Heidenhain {Host} backoff fault", HostName); + try { await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + } + + /// The reconnect delay for the n-th consecutive failure, clamped to sane bounds (never a hot loop). + internal TimeSpan BackoffDelay(int failures) + { + var r = _options.Reconnect ?? new HeidenhainReconnectOptions(); + var initial = TimeSpan.FromMilliseconds(Math.Clamp(r.InitialDelayMs, 100, 600_000)); + var max = TimeSpan.FromMilliseconds(Math.Clamp(r.MaxDelayMs, (int)initial.TotalMilliseconds, 600_000)); + return ConnectionBackoff.ComputeDelay(initial, failures, max); + } + + private async Task RunOnceAsync(CancellationToken ct) + { { HeidenhainSession? session = null; + _pollProgress = false; try { SetState(HeidenhainConnectionState.Connecting, null); @@ -161,7 +218,6 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable _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); @@ -170,7 +226,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - break; + throw; } catch (Exception ex) { @@ -188,15 +244,6 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable { 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; } } } @@ -204,9 +251,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable { 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), + [HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)), + [HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)), + [HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)), }; var start = _time.GetTimestamp(); var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero); @@ -257,6 +304,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable _groupGood[group] = good; _lastSuccessUtc = now; + _pollProgress = true; return true; } } @@ -325,6 +373,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable CancellationTokenSource? cts; lock (_lifecycle) { + _disposed = true; loop = _loop; cts = _cts; _loop = 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 index bf95afcc5..65e9ce831 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -42,6 +42,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi private readonly TimeProvider _time; private readonly SkippedTagLedger _skipped = new(); private readonly object _lifecycleLock = new(); + private readonly object _publishLock = new(); private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _subscriptions = new(); private HeidenhainDriverOptions _options; @@ -68,7 +69,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi { ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); - _options = options; + _options = HeidenhainOptionsParser.Normalize(options); _driverInstanceId = driverInstanceId; _logger = logger ?? NullLogger.Instance; _credentials = credentials; @@ -278,13 +279,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi { 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) + // Register + initial push under the publication lock, so no Publish can interleave: either it ran before + // (the push carries its value) or it runs after (it reaches this subscription). The subscriber can never + // receive an older snapshot after a newer one. + lock (_publishLock) { - var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now); - Raise(sub, r, snapshot); + _subscriptions[sub.Id] = sub; + 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(); @@ -298,17 +304,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi return Task.CompletedTask; } - private void Publish(string rawPath, DataValueSnapshot snapshot) + internal void Publish(string rawPath, DataValueSnapshot snapshot) { - var changed = true; - _cache.AddOrUpdate(rawPath, snapshot, (_, old) => + // Same lock as SubscribeAsync's register + initial push (see there). Lock order is always + // runtime publication → _publishLock; nothing takes them the other way round. + lock (_publishLock) { - 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); + var changed = !_cache.TryGetValue(rawPath, out var old) + || old.StatusCode != snapshot.StatusCode || !ValuesEqual(old.Value, snapshot.Value); + _cache[rawPath] = 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) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs index 55f0bdbde..44f9c3785 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs @@ -40,8 +40,16 @@ public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null try { string password; + // Resolved only through the namespaced resolver (fail-closed at resolve time): Test Connect can never be + // used to send another subsystem's secret or environment variable to an arbitrary Host. try { password = await new HeidenhainCredentials(secretResolver).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); } - catch (InvalidOperationException ex) { return new(false, ex.Message, null); } + catch (HeidenhainCredentialException ex) { return new(false, ex.Message, null); } + + try { _ = Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", password); } + catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException) + { + return new(false, "The INSPECT password cannot be sent in an LSV2 login (NUL, non-Latin-1 or too long).", null); + } var perCall = TimeSpan.FromMilliseconds(Math.Min(options.TimeoutMs, budget.TotalMilliseconds)); await using var client = new Lsv2Client(device.Host, device.Port, perCall); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs index de24db277..46b255b0d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs @@ -100,13 +100,42 @@ internal sealed class HeidenhainSession : IAsyncDisposable { try { - await _client.LoginAsync(role, await _passwords(role, ct).ConfigureAwait(false), ct).ConfigureAwait(false); + await LoginWithCredentialAsync(role, ct).ConfigureAwait(false); return HeidenhainStatus.Good; } catch (Lsv2RefusalException e) { return HeidenhainStatus.FromRefusal(e.Code); } + catch (QualityException e) + { + // An unusable optional-role credential degrades only the reads that need that role; INSPECT stays up. + return e.Status; + } + } + + /// + /// Resolves the role's password and logs in. A credential problem — an unresolvable reference, or a + /// password the policy-checked payload cannot carry (NUL, non-Latin-1, > 247 bytes) — becomes + /// (never sent, never a session failure). Refusals and + /// transport failures propagate unchanged. + /// + private async Task LoginWithCredentialAsync(string role, CancellationToken ct) + { + string password; + try { password = await _passwords(role, ct).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError); + } + + try { _ = Lsv2ReadOnlyPolicy.LoginPayload(role, password); } + catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException) + { + throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError); + } + + await _client.LoginAsync(role, password, ct).ConfigureAwait(false); } /// @@ -269,6 +298,12 @@ internal sealed class HeidenhainSession : IAsyncDisposable _client.Close(); return new Outcome(null, HeidenhainStatus.BadNoCommunication); } + catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException) + { + // A request the read-only policy refuses is never sent and never ends the session: this tag only. + if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.ConfigurationError; + return new Outcome(null, HeidenhainStatus.BadConfigurationError); + } } private async Task TimerAsync(string feature, CancellationToken ct) @@ -330,14 +365,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable { 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); + await LoginWithCredentialAsync(role, c).ConfigureAwait(false); return []; }, ct).ConfigureAwait(false); if (dnc) _dncStatus = HeidenhainStatus.Good; @@ -349,6 +377,13 @@ internal sealed class HeidenhainSession : IAsyncDisposable else _dataStatus = HeidenhainStatus.FromRefusal(e.Code); throw; } + catch (QualityException e) + { + // Credential problem: stable for this session (a fixed secret/variable is picked up on reconnect). + if (dnc) _dncStatus = e.Status; + else _dataStatus = e.Status; + throw; + } } private async Task PrepareTelemetryAsync(Dictionary wire, CancellationToken ct) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs index cc2182790..3b4fb4497 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs @@ -29,6 +29,12 @@ public static class HeidenhainStatus /// Any other controller refusal (retried next poll). public const uint BadUnexpectedError = 0x80010000u; + /// + /// An optional-role (DNC/DATA) credential cannot be used — unresolvable reference, or a password the login + /// payload cannot carry (NUL, non-Latin-1, too long). Degrades only the reads that need that role. + /// + public const uint BadConfigurationError = 0x80890000u; + /// A RawPath this driver has no authored tag for. public const uint BadNodeIdUnknown = 0x80340000u; @@ -48,7 +54,7 @@ public static class HeidenhainStatus /// 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; + public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied or BadConfigurationError; /// The capability state for a failure status. /// The status. @@ -59,6 +65,7 @@ public static class HeidenhainStatus BadNotSupported => HeidenhainCatalog.States.Unsupported, BadDecodingError or BadTypeMismatch or BadOutOfRange => HeidenhainCatalog.States.InvalidData, BadNoCommunication => HeidenhainCatalog.States.CommunicationLost, + BadConfigurationError => HeidenhainCatalog.States.ConfigurationError, _ => HeidenhainCatalog.States.Unknown, }; } 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 index a5702b726..5fa229ed3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainConfigTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainConfigTests.cs @@ -117,13 +117,94 @@ public sealed class HeidenhainConfigTests } [Theory] - [InlineData("secret:lsv2-inspect")] - [InlineData("env:LSV2_DNC_PASSWORD")] + [InlineData("secret:heidenhain/inspect")] + [InlineData("secret:heidenhain/line3/cnc-7.inspect")] + [InlineData("env:OTOPCUA_HEIDENHAIN_DNC_PASSWORD")] [InlineData("")] [InlineData(null)] - public void Credential_references_are_accepted(string? value) => + public void Namespaced_credential_references_are_accepted(string? value) => HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeTrue(); + /// + /// Review finding 2: an unscoped reference let a Designer exfiltrate any node secret by pointing Host at their + /// own machine and clicking Test Connect (the value is sent in cleartext in A_LG). + /// + [Theory] + [InlineData("env:ConnectionStrings__ConfigDb")] + [InlineData("env:ServerHistorian__ApiKey")] + [InlineData("env:HEIDENHAIN_PW")] + [InlineData("env:otopcua_heidenhain_pw")] + [InlineData("env:OTOPCUA_HEIDENHAIN_")] + [InlineData("env:OTOPCUA_HEIDENHAIN_A-B")] + [InlineData("secret:galaxy/inst1/apikey")] + [InlineData("secret:opcua/inst/password")] + [InlineData("secret:heidenhain/")] + [InlineData("secret:heidenhain/../galaxy/apikey")] + [InlineData("secret:heidenhain//x")] + [InlineData("secret:HEIDENHAIN/x")] + [InlineData("SECRET:heidenhain/x")] + [InlineData("secret:")] + [InlineData("hunter2")] + public void References_outside_the_heidenhain_namespace_are_refused(string value) + { + HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeFalse(value); + HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions { Access = new() { InspectPassword = value } }).ShouldNotBeEmpty(); + } + + [Theory] + [InlineData("env:ConnectionStrings__ConfigDb")] + [InlineData("secret:galaxy/inst1/apikey")] + public async Task The_resolver_fails_closed_for_out_of_namespace_references_without_consulting_store_or_environment(string reference) + { + var resolver = new FakeResolver(new() { ["galaxy/inst1/apikey"] = "GALAXY-KEY" }); + var envReads = 0; + var c = new HeidenhainCredentials(resolver, n => { envReads++; return "CONFIG-DB-CONNSTR"; }); + var ex = await Should.ThrowAsync(() => c.ResolveReferenceAsync(reference, "INSPECT", TestContext.Current.CancellationToken).AsTask()); + ex.Message.ShouldNotContain("GALAXY-KEY"); + ex.Message.ShouldNotContain("CONFIG-DB-CONNSTR"); + envReads.ShouldBe(0); + resolver.Reads.ShouldBe(0); + } + + [Fact] + public async Task The_probe_never_sends_an_out_of_namespace_credential() + { + var sent = 0; + await using var sim = Simulator.Lsv2Simulator.WithScript(async (conn, ct) => { while (await conn.ReadByteAsync(ct) >= 0) sent++; }); + var json = $$$"""{"Devices":[{"Host":"127.0.0.1","Port":{{{sim.Port}}},"DeviceName":"d"}],"Access":{"InspectPassword":"env:ConnectionStrings__ConfigDb"}}"""; + var result = await new HeidenhainDriverProbe(new FakeResolver(new())).ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + result.Ok.ShouldBeFalse(); + await Task.Delay(100, TestContext.Current.CancellationToken); + sim.Connections.ShouldBe(0); + sent.ShouldBe(0); + } + + [Theory] + [InlineData("""{"Access":null,"Polling":null,"Reconnect":null,"Devices":null,"RawTags":null}""")] + [InlineData("""{"Devices":[null],"RawTags":[null]}""")] + public void Null_sections_normalise_to_defaults(string json) + { + var o = HeidenhainOptionsParser.Parse(json); + o.Access.ShouldNotBeNull(); + o.Polling.MaxAlarms.ShouldBe(64); + o.Reconnect.InitialDelayMs.ShouldBe(1000); + o.Devices.ShouldBeEmpty(); + o.RawTags.ShouldBeEmpty(); + HeidenhainOptionsParser.Validate(o).ShouldBeEmpty(); + Should.NotThrow(() => HeidenhainDriver.ParseOptions(json)); + } + + [Fact] + public void Raw_config_scan_finds_literals_at_every_level_case_insensitively() + { + HeidenhainOptionsParser.DescribeCredentialProblems("""{"Host":"h","dncpassword":"hunter2"}""").Count.ShouldBe(1); + HeidenhainOptionsParser.DescribeCredentialProblems("""{"access":{"InspectPassword":"hunter2"}}""").Count.ShouldBe(1); + HeidenhainOptionsParser.DescribeCredentialProblems("""{"Devices":[{"DataPassword":"x"},{"DataPassword":7}]}""").Count.ShouldBe(2); + HeidenhainOptionsParser.DescribeCredentialProblems("""{"Access":{"InspectPassword":"secret:heidenhain/a"}}""").ShouldBeEmpty(); + string.Join(" ", HeidenhainOptionsParser.DescribeCredentialProblems("""{"Access":{"InspectPassword":"hunter2"}}""")).ShouldNotContain("hunter2"); + HeidenhainOptionsParser.DescribeCredentialProblems("not json").ShouldNotBeEmpty(); + } + [Theory] [InlineData("")] [InlineData("http://host")] @@ -139,25 +220,30 @@ public sealed class HeidenhainConfigTests [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 resolver = new FakeResolver(new() { ["heidenhain/lsv2"] = "s3cret" }); + var env = new Dictionary { ["OTOPCUA_HEIDENHAIN_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("secret:heidenhain/lsv2", "INSPECT", ct)).ShouldBe("s3cret"); + (await c.ResolveReferenceAsync("env:OTOPCUA_HEIDENHAIN_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()); + await Should.ThrowAsync(() => c.ResolveReferenceAsync("secret:heidenhain/missing", "DNC", ct).AsTask()); + await Should.ThrowAsync(() => c.ResolveReferenceAsync("env:OTOPCUA_HEIDENHAIN_UNSET", "DNC", ct).AsTask()); + await Should.ThrowAsync(() => c.ResolveReferenceAsync("literal", "DNC", ct).AsTask()); + await Should.ThrowAsync(() => new HeidenhainCredentials(null).ResolveReferenceAsync("secret:heidenhain/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"); + var o = new HeidenhainDriverOptions { Access = new() { InspectPassword = "secret:heidenhain/missing" } }; + (await c.ResolveAsync(o, new HeidenhainDeviceOptions { Host = "h", InspectPassword = "secret:heidenhain/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); + public int Reads { get; private set; } + + public Task GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct) + { + Reads++; + return 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 index 91ba62b6f..4ef3030e4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs @@ -275,7 +275,7 @@ public sealed class HeidenhainDriverTests { var registry = new DriverFactoryRegistry(); HeidenhainDriverFactoryExtensions.Register(registry); - var json = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"d"}],"Access":{"InspectPassword":"secret:lsv2/inspect"}}"""; + var json = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"d"}],"Access":{"InspectPassword":"secret:heidenhain/inspect"}}"""; var driver = registry.TryGet(DriverTypeNames.Heidenhain)!("hh", json); driver.ShouldBeOfType(); driver.DriverType.ShouldBe(DriverTypeNames.Heidenhain); 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 index 21897b8df..b0a9c4522 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; /// /// /// Env: HEIDENHAIN_LSV2_HOST (required), HEIDENHAIN_LSV2_PORT (default 19000), -/// HEIDENHAIN_LSV2_INSPECT_PASSWORD (optional; never logged). +/// OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD (optional; never logged). /// [Trait("Category", "LiveIntegration")] public sealed class HeidenhainLiveIntegrationTests @@ -28,7 +28,7 @@ public sealed class HeidenhainLiveIntegrationTests 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); + await client.LoginAsync("INSPECT", Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_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(); @@ -68,7 +68,7 @@ public sealed class HeidenhainLiveIntegrationTests { 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 }, + Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD") is { Length: > 0 } ? "env:OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD" : null }, }; await using var driver = new HeidenhainDriver(options, "live"); await driver.InitializeAsync("{}", ct); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainReviewFixTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainReviewFixTests.cs new file mode 100644 index 000000000..63dfc37e2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainReviewFixTests.cs @@ -0,0 +1,236 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging.Abstractions; +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; + +/// Regression tests for the independent-review findings 3–7 on feat/803 (each fails on the pre-fix code). +[Trait("Category", "Unit")] +public sealed class HeidenhainReviewFixTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static async Task<(Lsv2Simulator Sim, HeidenhainSession Session)> OpenAsync( + SimulatedControl control, HeidenhainSessionSettings settings, HeidenhainSession.PasswordSource passwords) + { + var sim = Lsv2Simulator.WithControl(control); + var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + var session = new HeidenhainSession(client, settings, passwords); + await client.OpenAsync(Ct); + await session.InitializeAsync(Ct); + return (sim, session); + } + + // ---- Finding 6: an unusable OPTIONAL-role credential degrades only the reads that need that role. ---- + + [Theory] + [InlineData("throw")] // unresolvable reference (unset variable / missing secret) + [InlineData("nul")] // a NUL the login payload cannot carry + [InlineData("non-latin1")] // € is not Latin-1 + [InlineData("too-long")] // A_LG payload > 247 bytes + public async Task Bad_dnc_credential_keeps_inspect_telemetry_and_sends_no_dnc_login(string kind) + { + var control = new SimulatedControl(); + control.Runtime[23] = () => SimulatedControl.U16(4); + var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false); + var (sim, session) = await OpenAsync(control, settings, (role, _) => role == "DNC" ? BadPassword(kind) : ValueTask.FromResult("")); + await using var _ = sim; + await using var __ = session; + var tags = new[] { TestTags.Def("Status/ExecutionState"), TestTags.Def("Capabilities/DncLogin") }; + var s1 = await session.ReadBatchAsync(tags, Ct); + var s2 = await session.ReadBatchAsync(tags, Ct); + s1[0].Value.ShouldBe(4); + s2[0].Value.ShouldBe(4); + s1[1].Value.ShouldBe("ConfigurationError"); + session.CommunicationLost.ShouldBeFalse(); + control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT" }); + } + + [Theory] + [InlineData("throw")] + [InlineData("nul")] + [InlineData("too-long")] + public async Task Bad_data_credential_is_BadConfigurationError_on_data_bindings_only(string kind) + { + var control = new SimulatedControl(); + control.Runtime[23] = () => SimulatedControl.U16(4); + control.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]); + var settings = new HeidenhainSessionSettings(true, false, true, 64, false, false, true); + var (sim, session) = await OpenAsync(control, settings, (role, _) => role == "DATA" ? BadPassword(kind) : ValueTask.FromResult("")); + await using var _ = sim; + await using var __ = session; + var tags = new[] { TestTags.Def("DATA:/x", "Int64"), TestTags.Def("Status/ExecutionState"), TestTags.Def("Capabilities/DataLogin") }; + for (var i = 0; i < 2; i++) + { + var s = await session.ReadBatchAsync(tags, Ct); + s[0].Status.ShouldBe(HeidenhainStatus.BadConfigurationError); + s[1].Value.ShouldBe(4); + s[2].Value.ShouldBe("ConfigurationError"); + } + + session.CommunicationLost.ShouldBeFalse(); + sim.Requests.ShouldNotContain(r => r.Command == "R_DP"); + control.Logins.ShouldNotContain(l => l.Role == "DATA"); + } + + private static ValueTask BadPassword(string kind) => kind switch + { + "throw" => throw new HeidenhainCredentialException("DATA password environment variable 'OTOPCUA_HEIDENHAIN_X' is not set (fail-closed)."), + "nul" => ValueTask.FromResult("a\0b"), + "non-latin1" => ValueTask.FromResult("p€ss"), + _ => ValueTask.FromResult(new string('x', 250)), + }; + + // ---- Finding 3: Start after DisposeAsync must not revive an orphan loop. ---- + + [Fact] + public async Task Start_after_dispose_is_a_no_op() + { + var control = new SimulatedControl(); + control.Runtime[23] = () => SimulatedControl.U16(4); + await using var sim = Lsv2Simulator.WithControl(control); + var runtime = NewRuntime(sim.Port, (_, _) => { }); + await runtime.DisposeAsync(); + runtime.Start(); + await Task.Delay(400, Ct); + sim.Connections.ShouldBe(0); + runtime.State.ShouldBe(HeidenhainConnectionState.Stopped); + } + + // ---- Finding 5: null sections must not NRE, and nothing may kill the loop silently. ---- + + [Fact] + public async Task Null_option_sections_from_a_constructed_options_object_do_not_kill_the_loop() + { + int port; + await using (var probe = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = probe.Port; + var options = new HeidenhainDriverOptions + { + Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }], + RawTags = [TestTags.Raw("Status/ExecutionState")], + Access = null!, + Polling = null!, + Reconnect = null!, + TimeoutMs = 300, + }; + var attempts = new ConcurrentQueue(); + await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null), + (h, p, t) => { attempts.Enqueue(DateTime.UtcNow); return new Lsv2Client(h, p, t); }, null); + await d.InitializeAsync("{}", Ct); + await d.SubscribeAsync([TestTags.Raw("Status/ExecutionState").RawPath], TimeSpan.FromSeconds(1), Ct); + await Task.Delay(3500, Ct); + attempts.Count.ShouldBeGreaterThanOrEqualTo(2); // the loop survived the first failure and retried + d.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + [Fact] + public async Task Null_option_sections_normalise_so_the_control_is_actually_read() + { + var control = new SimulatedControl(); + control.Runtime[23] = () => SimulatedControl.U16(4); + await using var sim = Lsv2Simulator.WithControl(control); + var tag = TestTags.Raw("Status/ExecutionState"); + var options = new HeidenhainDriverOptions + { + Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = sim.Port, DeviceName = "dev1" }], + RawTags = [tag], + Access = null!, + Polling = null!, + Reconnect = null!, + }; + await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null), null, null); + await d.InitializeAsync("{}", Ct); + await d.SubscribeAsync([tag.RawPath], TimeSpan.FromSeconds(1), Ct); + var deadline = DateTime.UtcNow.AddSeconds(5); + while ((await d.ReadAsync([tag.RawPath], Ct))[0].StatusCode != HeidenhainStatus.Good && DateTime.UtcNow < deadline) + await Task.Delay(50, Ct); + (await d.ReadAsync([tag.RawPath], Ct))[0].Value.ShouldBe(4); + } + + // ---- Finding 7: backoff resets only after real progress (a published poll batch). ---- + + [Fact] + public async Task A_control_that_accepts_login_then_drops_is_backed_off_not_hammered() + { + var control = new SimulatedControl(); + control.Runtime[23] = SimReply.Drop; // login + version succeed, the first poll read drops + await using var sim = Lsv2Simulator.WithControl(control); + var runtime = NewRuntime(sim.Port, (_, _) => { }); + await using var _ = runtime; + runtime.Start(); + await Task.Delay(2000, Ct); + // initial 100 ms doubling to 800 ms: ~100+200+400+800 ⇒ ≤ 6 connects in 2 s; pre-fix it reset to + // 100 ms after every login and dialled ~15 times. + sim.Connections.ShouldBeInRange(2, 6); + runtime.ConsecutiveFailures.ShouldBeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task Backoff_resets_after_a_session_that_published() + { + var reads = 0; + var control = new SimulatedControl(); + control.Runtime[23] = () => Interlocked.Increment(ref reads) % 3 == 0 ? SimReply.Drop() : SimulatedControl.U16(4); + await using var sim = Lsv2Simulator.WithControl(control); + var runtime = NewRuntime(sim.Port, (_, _) => { }); + await using var _ = runtime; + runtime.Start(); + await Task.Delay(1500, Ct); + runtime.ConsecutiveFailures.ShouldBeLessThanOrEqualTo(1); + } + + // ---- Finding 4: the initial subscription push can never deliver an older value after a newer one. ---- + + [Fact] + public async Task Initial_push_and_publish_are_ordered() + { + var options = new HeidenhainDriverOptions + { + Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = 1, DeviceName = "dev1" }], + RawTags = [TestTags.Raw("Status/ExecutionState")], + }; + await using var d = new HeidenhainDriver(options, "hh", null, new HeidenhainCredentials(null), null, null); + await d.InitializeAsync("{}", Ct); + var raw = options.RawTags[0].RawPath; + var perSub = new ConcurrentDictionary>(); + d.OnDataChange += (_, e) => + { + if (e.Snapshot.Value is int v) perSub.GetOrAdd(e.SubscriptionHandle, _ => []).Add(v); + }; + using var stop = new CancellationTokenSource(); + var publisher = Task.Run(() => + { + var now = DateTime.UtcNow; + for (var i = 1; i < 20_000 && !stop.IsCancellationRequested; i++) + d.Publish(raw, new DataValueSnapshot(i, HeidenhainStatus.Good, now, now)); + }, Ct); + for (var i = 0; i < 200; i++) + await d.SubscribeAsync([raw], TimeSpan.FromSeconds(1), Ct); + await publisher; + foreach (var (_, values) in perSub) + { + List copy; + lock (values) copy = values.ToList(); + for (var i = 1; i < copy.Count; i++) + copy[i].ShouldBeGreaterThan(copy[i - 1], "a subscriber saw an older value after a newer one"); + } + } + + private static HeidenhainDeviceRuntime NewRuntime(int port, Action publish) + { + var options = new HeidenhainDriverOptions + { + TimeoutMs = 500, + Polling = new HeidenhainPollingOptions { StatusMs = 100, CustomMs = 100, SlowMs = 100 }, + Reconnect = new HeidenhainReconnectOptions { InitialDelayMs = 100, MaxDelayMs = 800 }, + }; + var device = new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }; + return new HeidenhainDeviceRuntime(device, options, [TestTags.Def("Status/ExecutionState")], + (_, _) => ValueTask.FromResult(""), publish, (_, _, _) => { }, + (h, p, t) => new Lsv2Client(h, p, t), TimeProvider.System, NullLogger.Instance); + } +} From 54f58c583ca39626b321a48119d4391ec860c6ff Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:54:32 -0500 Subject: [PATCH 7/8] fix(heidenhain): refuse literal credentials and writable/mistyped tags at the save path (#803 review) - Review 1: RawTreeService Create/UpdateDriver and Create/UpdateDevice refuse a Heidenhain config whose password fields are not blank / secret:heidenhain/... / env:OTOPCUA_HEIDENHAIN_... (DriverConfigSaveGate over HeidenhainOptionsParser.DescribeCredentialProblems: top level, Access, Devices[], case-insensitive keys; the value is never echoed), so a literal password never reaches the draft DB. The driver and device modals show the reason and disable Save. - Review 8: CreateTag/UpdateTag/ImportTags (tag modal, manual entry, CSV import, browse commit) refuse a ReadWrite Heidenhain tag or a Tag.DataType that disagrees with what the driver publishes (HeidenhainTagRules: catalog type, Float64 => Double, or the binding's dataType). The tag modal and manual-entry grid fix the access level at Read and derive the data type from the address (RawTagRowDefaults). New deploy gate HeidenhainTagDataTypeMismatch. - docs/drivers/Heidenhain.md: credential namespace convention + why, the save-path and deploy enforcement, optional-role credential behaviour, BadConfigurationError, backoff/ordering notes. Tests: HeidenhainSavePathTests (service save path; red with the gates disabled) and new DraftValidatorHeidenhainTests cases. --- docs/drivers/Heidenhain.md | 55 +++++-- .../Validation/HeidenhainDeployChecks.cs | 13 ++ .../HeidenhainTagRules.cs | 54 +++++++ .../DeviceForms/HeidenhainDeviceForm.razor | 6 +- .../Shared/Drivers/DeviceModal.razor | 11 +- .../Shared/Drivers/DriverConfigModal.razor | 11 +- .../Drivers/Forms/HeidenhainDriverForm.razor | 6 +- .../Shared/Raw/RawManualTagEntryModal.razor | 8 +- .../Components/Shared/Raw/RawTagModal.razor | 25 ++- .../Uns/DriverConfigSaveGate.cs | 29 ++++ .../Uns/RawTagRowDefaults.cs | 32 ++++ .../Uns/RawTreeService.cs | 28 ++++ .../DraftValidatorHeidenhainTests.cs | 23 ++- .../Uns/HeidenhainAuthoringTests.cs | 8 +- .../Uns/HeidenhainSavePathTests.cs | 151 ++++++++++++++++++ 15 files changed, 427 insertions(+), 33 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainSavePathTests.cs diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index a30864d84..978088a90 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -16,9 +16,14 @@ Three independent layers, each sufficient on its own for its concern: 1. **No write capability.** `HeidenhainDriver` does not implement `IWritable`; `DriverInstanceActor` refuses every OPC write before it reaches the driver (`HeidenhainDriverEndToEndTests`). -2. **No writable nodes.** The deploy gate `HeidenhainTagWritable` refuses a `ReadWrite` access level on any tag - under a Heidenhain driver, so no node is ever advertised writable. A `"writable": true` TagConfig key is - rejected by the tag reader and dropped by the typed editor. +2. **No writable nodes.** A `ReadWrite` access level is refused at **authoring** — `RawTreeService` + create/update/import (so the tag modal, manual entry, CSV import and browse commit), where the modal and the + manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no + node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by + the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed + address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the + save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older + (one rule, `HeidenhainTagRules`). 3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s public surface is pinned by a reflection test) and no configuration knob that widens it. @@ -70,12 +75,31 @@ Device config (one `/raw` Device per control, AdminUI **HeidenhainDeviceForm**): `Port` (19000), optional per-device `InspectPassword`/`DncPassword`/`DataPassword` overrides. A device is validated only once it carries tags, so an unconfigured default `Device1` does not fault the driver. -**Credentials.** A password field is blank, `secret:` (the node's encrypted secret store, via -`ISecretResolver`) or `env:`. It is resolved **at each login** and never held in options or session -state. A literal is refused by the parser, both AdminUI forms and the deploy gate `HeidenhainCredentialPersisted` -(the message never echoes the value). Fail-closed: an absent secret / unset variable fails the login rather than -sending the reference text. Plain LSV2 is unencrypted — reach the control over an externally managed secure -network; there is no SSH subsystem. +**Credentials — namespaced references only.** A password field is one of: + +| Form | Resolved from | +|---|---| +| blank | no password | +| `secret:heidenhain/` (e.g. `secret:heidenhain/line3/inspect`; `[A-Za-z0-9._-]` segments, no `..`) | the node's encrypted secret store (`ISecretResolver`), same slash-scoped naming as `galaxy//apikey` | +| `env:OTOPCUA_HEIDENHAIN_` (exact upper-case prefix, `NAME` = `[A-Z0-9_]+`) | the process environment | + +**Why namespaced:** the reference is resolved on the node (for Test Connect: on the admin node) and sent in +**cleartext** in `A_LG` to whatever Host the author configured. An unscoped reference would let anyone who can edit +a driver exfiltrate `env:ConnectionStrings__ConfigDb`, `env:ServerHistorian__ApiKey` or any other secret by pointing +Host at their own machine. Anything outside the namespace is refused by **every** layer: the parser +(`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time** +(`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path** +(`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the +driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the +deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. + +Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset +variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the +connect (INSPECT is required). An unusable **optional** DNC/DATA credential — unresolvable, or a password the LSV2 +login payload cannot carry (NUL, non-Latin-1, > 247 bytes) — never fails the session: the login is not sent, the +reads that need that role read `BadConfigurationError` (capability `ConfigurationError`), and INSPECT telemetry +continues. Plain LSV2 is unencrypted — reach the control over an externally managed secure network; there is no +SSH subsystem. ## Addresses (TagConfig) @@ -134,6 +158,7 @@ At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/ | Binding type mismatch | `BadTypeMismatch` | | Out-of-region binding, alarm overflow | `BadOutOfRange` / `InvalidData` (never a truncated list) | | Disabled feature (telemetry off, timers off, DATA off) | `BadNotSupported` / `Disabled` | +| Unusable optional DNC/DATA credential, or a request the read-only policy refuses | `BadConfigurationError` / `ConfigurationError` — that role's reads only; never sent; the session stays up | | Not yet acquired | `BadWaitingForInitialData` | | Transport lost | **every** remote value of that control `BadNoCommunication` at once; capabilities `CommunicationLost` | @@ -150,8 +175,14 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re **generation**; a batch returning after its session was retired can never overwrite the outage invalidation. - **Reads are cache-only** (`ReadAsync` never touches the wire); subscriptions push on (value, **status**) change, so a control returning with identical readings still re-publishes Good. -- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`). Unlike the Java base, an - initial connect failure also retries (no manual restart). +- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`, clamped to 100 ms–10 min). + The backoff resets only after a session **published a poll batch** — a control that accepts login + version and + then drops is backed off, not re-dialled every `InitialDelayMs`. Unlike the Java base, an initial connect + failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off); + JSON-`null` config sections are normalised to defaults by the parser. +- Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never + receives an older value after a newer one. A re-init disposes every control loop first; a late start of a + disposed loop is a no-op. - **Health:** `Degraded` only while a control's session is down (connect failing / lost), with `LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one @@ -174,7 +205,7 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no - `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet parity suites. - **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional - `HEIDENHAIN_LSV2_PORT`, `HEIDENHAIN_LSV2_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a + `HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a driver end-to-end status poll. Record its output as .NET commissioning evidence. ## Hardware-gated — not yet proven diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs index 0518155e8..4b134c8ba 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs @@ -30,6 +30,9 @@ public static class HeidenhainDeployChecks /// A literal password in a Heidenhain DriverConfig/DeviceConfig. public const string CredentialCode = "HeidenhainCredentialPersisted"; + /// A Heidenhain tag whose Tag.DataType disagrees with the type the driver publishes. + public const string DataTypeCode = "HeidenhainTagDataTypeMismatch"; + /// Invalid Heidenhain settings or endpoint. public const string ConfigCode = "HeidenhainConfigInvalid"; @@ -56,6 +59,16 @@ public static class HeidenhainDeployChecks tag.TagId)); } + foreach (var tag in tags) + { + var expected = HeidenhainTagRules.ExpectedTagDataType(tag.TagConfig); + if (expected is null || string.Equals(expected, tag.DataType, StringComparison.OrdinalIgnoreCase)) continue; + errors.Add(new(DataTypeCode, + $"Tag '{tag.Name}' on Heidenhain driver '{driver.Name}' has data type {tag.DataType}, but the driver " + + $"publishes {expected} for its address — the node would advertise a type its values never carry.", + tag.TagId)); + } + HeidenhainDriverOptions options; try { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs new file mode 100644 index 000000000..8e454d000 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs @@ -0,0 +1,54 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// +/// Rules about the Tag ROW (not its TagConfig) for a Heidenhain tag: the one authority shared by every AdminUI +/// authoring path (RawTagModal, manual entry, CSV import, browse commit — all through RawTreeService) +/// and the deploy gate, so authoring and deploy can never disagree. +/// +public static class HeidenhainTagRules +{ + /// + /// The Tag.DataType (OPC UA built-in type name) a Heidenhain tag must carry for the value the driver + /// publishes, or null when the TagConfig does not resolve (the TagConfig gate reports that). + /// + /// The TagConfig JSON. + /// e.g. Int32, Double, String; null when unresolvable. + public static string? ExpectedTagDataType(string? tagConfig) => + HeidenhainTagDefinitionFactory.TryFromTagConfig("rule", "", tagConfig, out var def, out _) + ? OpcTypeName(def.DataType) + : null; + + /// The OPC UA built-in type name (the Tag.DataType vocabulary) for a published data type. + /// The driver data type. + /// The type name. + public static string OpcTypeName(DriverDataType type) => type switch + { + DriverDataType.Boolean => "Boolean", + DriverDataType.Int16 => "Int16", + DriverDataType.Int32 => "Int32", + DriverDataType.Int64 => "Int64", + DriverDataType.Float64 => "Double", + DriverDataType.String => "String", + _ => type.ToString(), + }; + + /// + /// Why a Heidenhain tag row may not be saved / deployed: a writable access level (the driver is read-only), + /// or a Tag.DataType that disagrees with what the driver publishes for its address. + /// + /// Whether the row's access level is ReadWrite. + /// The row's Tag.DataType. + /// The row's TagConfig. + /// The reason, or null when the row is acceptable. + public static string? DescribeRowProblem(bool readWrite, string? tagDataType, string? tagConfig) + { + if (readWrite) + return "Heidenhain tags are read-only — set the access level to Read (the driver never writes to the control)."; + var expected = ExpectedTagDataType(tagConfig); + if (expected is not null && !string.Equals(expected, tagDataType, StringComparison.OrdinalIgnoreCase)) + return $"Data type {tagDataType} does not match what the driver publishes for this address ({expected})."; + return null; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor index ecaeade4b..ec8829186 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor @@ -27,15 +27,15 @@
- +
- +
- +
Blank = use the driver-level reference. secret:<name> resolves from the node's secret store, env:<VAR> from the environment, at each login. A literal password is never stored.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index eaebf2068..fb380aaf8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -62,6 +62,10 @@ + @if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is { } gateError) + { +
@gateError
+ } @if (_saveError is not null) {
@_saveError
@@ -73,7 +77,8 @@ @if (_loadError is null) { - @@ -195,7 +200,9 @@ private async Task SaveAsync() { - _busy = true; _saveError = null; + _saveError = DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson); + if (_saveError is not null) return; + _busy = true; try { UnsMutationResult result; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index 8c3e7047a..3a415b596 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -63,6 +63,10 @@

} + @if (DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is { } gateError) + { +
@gateError
+ } @if (_saveError is not null) {
@_saveError
@@ -74,7 +78,8 @@ @if (_loadError is null) { - @@ -185,7 +190,9 @@ private async Task SaveAsync() { - _busy = true; _saveError = null; + _saveError = DriverConfigSaveGate.Validate(_driverType, _driverConfigJson); + if (_saveError is not null) return; + _busy = true; try { UnsMutationResult result; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor index 6c90e3753..ee0e6b005 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor @@ -59,15 +59,15 @@
- +
- +
- +
References only: blank, secret:<name> (node secret store) or env:<VAR>. Resolved at each login; a literal password is refused.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor index 05ef3dbb7..abfe80c7c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor @@ -59,6 +59,7 @@ +
This driver is read-only.
+ } + else + { + + + + + }
@@ -226,7 +234,11 @@ { ["ConfigJson"] = _form.TagConfig, ["ConfigJsonChanged"] = _configJsonChanged ??= - EventCallback.Factory.Create(this, v => _form.TagConfig = v), + EventCallback.Factory.Create(this, v => + { + _form.TagConfig = v; + RawTagRowDefaults.Apply(DriverType, _form.TagConfig, a => _form.AccessLevel = a, d => _form.DataType = d); + }), ["DriverType"] = DriverType ?? "", // Live accessor, not a snapshot: the picker reads it when the operator clicks Browse. ["GetDriverConfigJson"] = _getDriverConfigJson ??= () => _mergedConfigJson ?? "{}", @@ -258,6 +270,7 @@ return; } + RawTagRowDefaults.Apply(DriverType, _form.TagConfig, a => _form.AccessLevel = a, d => _form.DataType = d); var input = new RawTagInput( _form.Name, _form.DataType, @@ -283,6 +296,8 @@ } } + private bool IsReadOnlyDriver => RawTagRowDefaults.IsReadOnlyDriver(DriverType); + private async Task CloseAsync() { Visible = false; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs new file mode 100644 index 000000000..9b46518e7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs @@ -0,0 +1,29 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Server-side refusal of a DriverConfig / DeviceConfig that must never reach the config database. Called by +/// on every driver/device create + update (the authority — a modal that skips +/// its own check, the API or a hand-crafted circuit message still hits it) and by the driver/device modals to +/// block Save while the form is invalid. +/// +/// +/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain +/// namespace () — otherwise a literal password +/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it. +/// +public static class DriverConfigSaveGate +{ + /// The reason a config may not be saved for a driver type, or null when it may. + /// The owning driver's type. + /// The DriverConfig or DeviceConfig JSON about to be stored. + /// The refusal (never containing a credential value), or null. + public static string? Validate(string? driverType, string? configJson) + { + if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null; + var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson); + return problems.Count == 0 ? null : string.Join(" ", problems); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs new file mode 100644 index 000000000..5718c756c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs @@ -0,0 +1,32 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the +/// access level is always Read and Tag.DataType follows the address (catalog type, or the binding's +/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the +/// server (RawTreeService) and the deploy gate would refuse. +/// +public static class RawTagRowDefaults +{ + /// True for a driver whose tags can never be writable. + /// The driver type. + /// Whether the access level is fixed at Read. + public static bool IsReadOnlyDriver(string? driverType) => + string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase); + + /// Applies the driver-dictated access level and data type for a row, when the driver dictates them. + /// The driver type. + /// The row's TagConfig. + /// Sets the row's access level. + /// Sets the row's Tag.DataType. + public static void Apply(string? driverType, string? tagConfig, Action setAccess, Action setDataType) + { + if (!IsReadOnlyDriver(driverType)) return; + setAccess(TagAccessLevel.Read); + if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 0a8a904fd..74172dfd7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -483,6 +483,8 @@ public sealed class RawTreeService(IDbContextFactory dbF if (nameError is not null) return new UnsMutationResult(false, nameError); if (string.IsNullOrWhiteSpace(driverType)) return new UnsMutationResult(false, "Pick a driver type."); + if (DriverConfigSaveGate.Validate(driverType, driverConfigJson) is { } configRefusal) + return new UnsMutationResult(false, configRefusal); await using var db = await dbFactory.CreateDbContextAsync(ct); @@ -619,6 +621,8 @@ public sealed class RawTreeService(IDbContextFactory dbF var owner = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (owner is null) return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found."); + if (DriverConfigSaveGate.Validate(owner.DriverType, deviceConfigJson) is { } configRefusal) + return new UnsMutationResult(false, configRefusal); // MTConnect owns one agent client per driver — mirror the deploy gate // (DraftValidator.ValidateMTConnectSingleDevice) so the second Device is refused where it is authored. @@ -855,6 +859,8 @@ public sealed class RawTreeService(IDbContextFactory dbF if (await ValidateTagConfigAsync(db, deviceId, tagConfig, ct) is { } configError) return new UnsMutationResult(false, configError); + if (TagRowProblem(await ResolveDriverTypeAsync(db, deviceId, ct), input, tagConfig) is { } rowError) + return new UnsMutationResult(false, rowError); var id = NewId("TAG"); db.Tags.Add(new Tag @@ -896,6 +902,8 @@ public sealed class RawTreeService(IDbContextFactory dbF if (await ValidateTagConfigAsync(db, entity.DeviceId, tagConfig, ct) is { } configError) return new UnsMutationResult(false, configError); + if (TagRowProblem(await ResolveDriverTypeAsync(db, entity.DeviceId, ct), input, tagConfig) is { } rowError) + return new UnsMutationResult(false, rowError); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = input.Name; @@ -989,6 +997,12 @@ public sealed class RawTreeService(IDbContextFactory dbF continue; } + if (TagRowProblem(driverType, row.Tag, tagConfig) is { } rowError) + { + errors.Add($"{rowLabel}: {rowError}"); + continue; + } + if (!takenNames.Add((groupId, row.Tag.Name))) { errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path."); @@ -1102,6 +1116,8 @@ public sealed class RawTreeService(IDbContextFactory dbF var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + if (DriverConfigSaveGate.Validate(entity.DriverType, driverConfigJson) is { } configRefusal) + return new UnsMutationResult(false, configRefusal); // Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder). if (await db.DriverInstances.AnyAsync( @@ -1162,6 +1178,8 @@ public sealed class RawTreeService(IDbContextFactory dbF var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + if (DriverConfigSaveGate.Validate(await ResolveDriverTypeAsync(db, deviceId, ct), deviceConfigJson) is { } configRefusal) + return new UnsMutationResult(false, configRefusal); if (await db.Devices.AnyAsync( dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == name && dev.DeviceId != deviceId, ct)) @@ -1275,6 +1293,16 @@ public sealed class RawTreeService(IDbContextFactory dbF /// The normalised TagConfig JSON about to be persisted. /// Cancellation token. /// An operator-facing error, or when the config is acceptable. + /// + /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only + /// and carry the data type the driver publishes — the same rule the deploy gate applies. + /// + private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) => + string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase) + ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem( + input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig) + : null; + private static async Task ValidateTagConfigAsync( OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct) { diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs index a71303447..52de5a589 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs @@ -56,7 +56,7 @@ public sealed class DraftValidatorHeidenhainTests [Fact] public void Secret_and_env_references_are_accepted() - => Validate(Driver("""{"Access":{"InspectPassword":"secret:lsv2/inspect","DncPassword":"env:LSV2_DNC"}}"""), Dev(), T(TagAccessLevel.Read)) + => Validate(Driver("""{"Access":{"InspectPassword":"secret:heidenhain/inspect","DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC"}}"""), Dev(), T(TagAccessLevel.Read)) .ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); [Fact] @@ -66,6 +66,27 @@ public sealed class DraftValidatorHeidenhainTests Validate(Driver(), Dev("{}")).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); } + [Fact] + public void A_tag_data_type_that_disagrees_with_the_published_type_is_refused() + { + var tag = T(TagAccessLevel.Read); + tag.DataType = "Float"; + var e = Validate(Driver(), Dev(), tag).Single(x => x.Code == HeidenhainDeployChecks.DataTypeCode); + e.Context.ShouldBe("t1"); + e.Message.ShouldContain("Int32"); + var binding = T(TagAccessLevel.Read); + binding.TagConfig = """{"address":"PLC:DWORD:0","dataType":"Int64"}"""; + binding.DataType = "Int64"; + Validate(Driver(), Dev(), binding).ShouldNotContain(x => x.Code == HeidenhainDeployChecks.DataTypeCode); + } + + [Theory] + [InlineData("env:ConnectionStrings__ConfigDb")] + [InlineData("secret:galaxy/inst1/apikey")] + public void An_out_of_namespace_reference_is_refused(string reference) + => Validate(Driver("{\"Access\":{\"InspectPassword\":\"" + reference + "\"}}"), Dev(), T(TagAccessLevel.Read)) + .ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode); + [Fact] public void Out_of_range_settings_are_refused() => Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs index 9d872e286..d7b59a66d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs @@ -35,13 +35,13 @@ public sealed class HeidenhainAuthoringTests var authored = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"m1"}],"Custom":42}"""; var m = HeidenhainDriverFormModel.FromJson(authored); m.Validate().ShouldBeNull(); - m.InspectPassword = "secret:lsv2/inspect"; + m.InspectPassword = "secret:heidenhain/inspect"; m.TimersEnabled = true; var json = m.ToJson(); var o = HeidenhainOptionsParser.Parse(json); HeidenhainOptionsParser.Validate(o).ShouldBeEmpty(); // the factory's ParseOptions gate o.Devices.Single().Host.ShouldBe("10.0.0.5"); - o.Access.InspectPassword.ShouldBe("secret:lsv2/inspect"); + o.Access.InspectPassword.ShouldBe("secret:heidenhain/inspect"); o.Polling.TimersEnabled.ShouldBeTrue(); JsonNode.Parse(json)!["Custom"]!.GetValue().ShouldBe(42); // no field is ever written as a number where the parser expects a name — there are no enums at all @@ -67,11 +67,11 @@ public sealed class HeidenhainAuthoringTests d.Validate().ShouldBeNull(); d.DataPassword = "plain"; d.Validate().ShouldNotBeNull(); - d.DataPassword = "env:LSV2_DATA"; + d.DataPassword = "env:OTOPCUA_HEIDENHAIN_DATA"; var json = d.ToJson(); JsonNode.Parse(json)!["Note"]!.GetValue().ShouldBe("keep"); var opts = HeidenhainOptionsParser.Parse($$"""{"Devices":[{{json}}]}"""); - opts.Devices.Single().DataPassword.ShouldBe("env:LSV2_DATA"); + opts.Devices.Single().DataPassword.ShouldBe("env:OTOPCUA_HEIDENHAIN_DATA"); HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host required } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainSavePathTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainSavePathTests.cs new file mode 100644 index 000000000..28d1f0a8c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainSavePathTests.cs @@ -0,0 +1,151 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// #803 review findings 1 + 8 at the SAVE path (RawTreeService), not just the editor models: a literal or +/// out-of-namespace Heidenhain password never reaches the config DB, and a Heidenhain tag row is refused when it +/// is ReadWrite or carries a data type the driver does not publish — through every authoring entry point +/// (modal create/update, manual entry = CreateTagAsync, CSV/browse = ImportTagsAsync). +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainSavePathTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static async Task<(RawTreeService Svc, string Db, string DriverId, string DeviceId)> HeidenhainAsync() + { + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, "{}", Ct); + created.Ok.ShouldBeTrue(created.Error); + using var db = RawTreeTestDb.CreateNamed(name); + var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId); + return (svc, name, created.CreatedId!, device.DeviceId); + } + + // ------------------------------------------------------------------ finding 1: credentials never persisted + + [Theory] + [InlineData("""{"Access":{"InspectPassword":"hunter2"}}""")] + [InlineData("""{"access":{"dncpassword":"hunter2"}}""")] + [InlineData("""{"Access":{"DataPassword":"env:ConnectionStrings__ConfigDb"}}""")] + [InlineData("""{"Devices":[{"Host":"h","InspectPassword":"secret:galaxy/inst1/apikey"}]}""")] + public async Task CreateDriver_refuses_a_literal_or_out_of_namespace_password_and_persists_nothing(string config) + { + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, config, Ct); + result.Ok.ShouldBeFalse(); + result.Error!.ShouldNotContain("hunter2"); + using var db = RawTreeTestDb.CreateNamed(name); + db.DriverInstances.ShouldNotContain(d => d.Name == "hh1"); + } + + [Fact] + public async Task UpdateDriver_refuses_a_literal_password_and_keeps_the_stored_config() + { + var (svc, dbName, driverId, _) = await HeidenhainAsync(); + byte[] rv; + using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion; + var result = await svc.UpdateDriverAsync(driverId, "hh1", """{"Access":{"InspectPassword":"hunter2"}}""", null, rv, Ct); + result.Ok.ShouldBeFalse(); + using var check = RawTreeTestDb.CreateNamed(dbName); + check.DriverInstances.Single(d => d.DriverInstanceId == driverId).DriverConfig.ShouldNotContain("hunter2"); + } + + [Fact] + public async Task CreateDevice_and_UpdateDevice_refuse_a_literal_password() + { + var (svc, dbName, driverId, deviceId) = await HeidenhainAsync(); + (await svc.CreateDeviceAsync(driverId, "cnc2", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""", Ct)).Ok.ShouldBeFalse(); + byte[] rv; + using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.Devices.Single(d => d.DeviceId == deviceId).RowVersion; + (await svc.UpdateDeviceAsync(deviceId, "Device1x", """{"Host":"10.0.0.5","DataPassword":"hunter2"}""", true, rv, Ct)).Ok.ShouldBeFalse(); + using var check = RawTreeTestDb.CreateNamed(dbName); + check.Devices.Where(d => d.DriverInstanceId == driverId).ShouldAllBe(d => !d.DeviceConfig.Contains("hunter2")); + } + + [Fact] + public async Task Namespaced_references_save_and_other_drivers_are_unaffected() + { + var (svc, _, driverId, _) = await HeidenhainAsync(); + (await svc.CreateDeviceAsync(driverId, "cnc2", + """{"Host":"10.0.0.5","InspectPassword":"secret:heidenhain/cnc2/inspect","DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC"}""", Ct)).Ok.ShouldBeTrue(); + // A Modbus driver's config is none of this gate's business. + (await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "mb9", DriverTypeNames.Modbus, """{"InspectPassword":"x"}""", Ct)).Ok.ShouldBeTrue(); + } + + [Fact] + public void The_modal_save_gate_is_the_same_rule() + { + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"hunter2"}}""").ShouldNotBeNull(); + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:heidenhain/a"}}""").ShouldBeNull(); + DriverConfigSaveGate.Validate(DriverTypeNames.Modbus, """{"Access":{"InspectPassword":"hunter2"}}""").ShouldBeNull(); + } + + // ------------------------------------------------------------------ finding 8: tag rows + + private static RawTagInput Row(string name, string dataType, TagAccessLevel access, string config) => + new(name, dataType, access, WriteIdempotent: false, PollGroupId: null, config); + + private const string Exec = """{"address":"Status/ExecutionState"}"""; + + [Fact] + public async Task CreateTag_refuses_ReadWrite_and_a_mismatched_data_type() + { + var (svc, _, _, deviceId) = await HeidenhainAsync(); + (await svc.CreateTagAsync(deviceId, null, Row("a", "Int32", TagAccessLevel.ReadWrite, Exec), Ct)).Ok.ShouldBeFalse(); + (await svc.CreateTagAsync(deviceId, null, Row("b", "Float", TagAccessLevel.Read, Exec), Ct)).Ok.ShouldBeFalse(); + (await svc.CreateTagAsync(deviceId, null, Row("c", "Int32", TagAccessLevel.Read, Exec), Ct)).Ok.ShouldBeTrue(); + (await svc.CreateTagAsync(deviceId, null, + Row("d", "Double", TagAccessLevel.Read, """{"address":"Axes/Positions","isArray":true}"""), Ct)).Ok.ShouldBeTrue(); + (await svc.CreateTagAsync(deviceId, null, + Row("e", "Int64", TagAccessLevel.Read, """{"address":"PLC:DWORD:0","dataType":"Int64"}"""), Ct)).Ok.ShouldBeTrue(); + (await svc.CreateTagAsync(deviceId, null, + Row("f", "Int32", TagAccessLevel.Read, """{"address":"PLC:DWORD:0","dataType":"Int64"}"""), Ct)).Ok.ShouldBeFalse(); + } + + [Fact] + public async Task UpdateTag_refuses_ReadWrite() + { + var (svc, dbName, _, deviceId) = await HeidenhainAsync(); + var created = await svc.CreateTagAsync(deviceId, null, Row("a", "Int32", TagAccessLevel.Read, Exec), Ct); + byte[] rv; + using (var db = RawTreeTestDb.CreateNamed(dbName)) rv = db.Tags.Single(t => t.TagId == created.CreatedId).RowVersion; + (await svc.UpdateTagAsync(created.CreatedId!, Row("a", "Int32", TagAccessLevel.ReadWrite, Exec), rv, Ct)).Ok.ShouldBeFalse(); + using var check = RawTreeTestDb.CreateNamed(dbName); + check.Tags.Single(t => t.TagId == created.CreatedId).AccessLevel.ShouldBe(TagAccessLevel.Read); + } + + [Fact] + public async Task ImportTags_names_the_ReadWrite_and_mismatched_rows_and_inserts_nothing() + { + var (svc, dbName, _, deviceId) = await HeidenhainAsync(); + var outcome = await svc.ImportTagsAsync(deviceId, + [ + new RawTagImportRow(null, Row("ok", "Int32", TagAccessLevel.Read, Exec)), + new RawTagImportRow(null, Row("rw", "Int32", TagAccessLevel.ReadWrite, Exec)), + new RawTagImportRow(null, Row("dt", "String", TagAccessLevel.Read, Exec)), + ], Ct); + outcome.Errors.Count.ShouldBe(2); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Tags.ShouldNotContain(t => t.DeviceId == deviceId); + } + + [Fact] + public void Row_defaults_force_Read_and_derive_the_data_type() + { + var access = TagAccessLevel.ReadWrite; + var dataType = "Float"; + RawTagRowDefaults.Apply(DriverTypeNames.Heidenhain, """{"address":"Overrides/Feed"}""", a => access = a, d => dataType = d); + access.ShouldBe(TagAccessLevel.Read); + dataType.ShouldBe("Double"); + RawTagRowDefaults.Apply(DriverTypeNames.Modbus, Exec, a => access = TagAccessLevel.ReadWrite, d => dataType = "x"); + dataType.ShouldBe("Double"); // untouched for other drivers + } +} From f05e81710c419ce44cfe597976b317226a6940c4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 23:54:44 -0500 Subject: [PATCH 8/8] docs(rules): Heidenhain credential namespace note (#803 review) --- .claude/rules/drivers.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 4efac5d67..ed6ea20bf 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -305,7 +305,10 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `Lsv2ReadOnlyPolicy` (payload-checked in `Lsv2Codec.Frame`, the only frame builder; logins + R_VR/R_PR/R_RI/R_MB/R_DP only). Never widen the allowlist to work around a controller refusal. Init opens nothing (acquisition starts on the first subscribe/read); reads are cache-only; a refusal/denial/decode error is per-tag quality and does NOT degrade - health — only a down session does (with `LastSuccessfulRead` frozen). Passwords are `secret:`/`env:` refs only + health — only a down session does (with `LastSuccessfulRead` frozen). Passwords are NAMESPACED refs only — + `secret:heidenhain/…` / `env:OTOPCUA_HEIDENHAIN_…`, enforced at parse, resolve, save path, probe and deploy (an + unscoped ref let Test Connect exfiltrate any node secret in cleartext A_LG); a bad optional DNC/DATA credential is + `BadConfigurationError` on that role's reads only (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's