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.
This commit is contained in:
@@ -62,6 +62,9 @@ public static class DriverTypeNames
|
||||
/// opt-in observation write-back via the Agent's AllowPut POST surface.</summary>
|
||||
public const string MTConnect = "MTConnect";
|
||||
|
||||
/// <summary>Heidenhain LSV2 CNC driver — native, read-only (logins + read requests only) over TCP 19000.</summary>
|
||||
public const string Heidenhain = "Heidenhain";
|
||||
|
||||
/// <summary>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
@@ -80,5 +83,6 @@ public static class DriverTypeNames
|
||||
Sql,
|
||||
Mqtt,
|
||||
MTConnect,
|
||||
Heidenhain,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>The PLC memory areas a <c>PLC:</c> binding may name (advertised by the control's <c>R_PR</c> layout).</summary>
|
||||
public enum HeidenhainPlcArea
|
||||
{
|
||||
Marker,
|
||||
Input,
|
||||
Output,
|
||||
Counter,
|
||||
Timer,
|
||||
Byte,
|
||||
Word,
|
||||
Dword,
|
||||
String,
|
||||
InputWord,
|
||||
OutputWord,
|
||||
}
|
||||
|
||||
/// <summary>What an address resolves to.</summary>
|
||||
public enum HeidenhainAddressKind
|
||||
{
|
||||
/// <summary>A fixed catalog leaf (<see cref="HeidenhainCatalog"/>).</summary>
|
||||
Standard,
|
||||
|
||||
/// <summary>A PLC memory element read via <c>R_MB</c>.</summary>
|
||||
Plc,
|
||||
|
||||
/// <summary>A data-path value read via <c>R_DP</c> (DATA role).</summary>
|
||||
DataPath,
|
||||
}
|
||||
|
||||
/// <summary>A parsed, validated Heidenhain tag address.</summary>
|
||||
/// <param name="Kind">The address family.</param>
|
||||
/// <param name="Text">The canonical address text.</param>
|
||||
/// <param name="Catalog">The fixed leaf, for <see cref="HeidenhainAddressKind.Standard"/>.</param>
|
||||
/// <param name="Area">The PLC area, for <see cref="HeidenhainAddressKind.Plc"/>.</param>
|
||||
/// <param name="Index">The PLC <b>element</b> index (not an absolute address), for <see cref="HeidenhainAddressKind.Plc"/>.</param>
|
||||
/// <param name="DataPath">The data path as authored, for <see cref="HeidenhainAddressKind.DataPath"/>.</param>
|
||||
public sealed record HeidenhainAddress(
|
||||
HeidenhainAddressKind Kind,
|
||||
string Text,
|
||||
HeidenhainCatalogEntry? Catalog = null,
|
||||
HeidenhainPlcArea? Area = null,
|
||||
long Index = 0,
|
||||
string? DataPath = null)
|
||||
{
|
||||
/// <summary>Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header).</summary>
|
||||
public const int MaxPayload = 247;
|
||||
|
||||
/// <summary>Address prefix of a PLC binding.</summary>
|
||||
public const string PlcPrefix = "PLC:";
|
||||
|
||||
/// <summary>Address prefix of a DATA-path binding.</summary>
|
||||
public const string DataPrefix = "DATA:";
|
||||
|
||||
/// <summary>The data types a custom (PLC/DATA) binding may declare — the reference's BOOLEAN/INT16/INT32/INT64/DOUBLE/STRING.</summary>
|
||||
public static readonly IReadOnlySet<DriverDataType> CustomDataTypes = new HashSet<DriverDataType>
|
||||
{
|
||||
DriverDataType.Boolean, DriverDataType.Int16, DriverDataType.Int32,
|
||||
DriverDataType.Int64, DriverDataType.Float64, DriverDataType.String,
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, HeidenhainPlcArea> AreaNames =
|
||||
new Dictionary<string, HeidenhainPlcArea>(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,
|
||||
};
|
||||
|
||||
/// <summary>The wire spelling of a PLC area (<c>MARKER</c>, <c>INPUT_WORD</c>, …).</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>Its address token.</returns>
|
||||
public static string AreaToken(HeidenhainPlcArea area) => AreaNames.First(kv => kv.Value == area).Key;
|
||||
|
||||
/// <summary>The PLC area tokens, in declaration order.</summary>
|
||||
public static IReadOnlyList<string> AreaTokens { get; } = AreaNames.Keys.ToList();
|
||||
|
||||
/// <summary>True for the one-byte-per-entry Boolean bit areas.</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>Whether it is a bit area.</returns>
|
||||
public static bool IsBitArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Marker or HeidenhainPlcArea.Input
|
||||
or HeidenhainPlcArea.Output or HeidenhainPlcArea.Counter or HeidenhainPlcArea.Timer;
|
||||
|
||||
/// <summary>True for the integer areas (BYTE/WORD/DWORD signed; INPUT_WORD/OUTPUT_WORD unsigned 16-bit).</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>Whether it is an integer area.</returns>
|
||||
public static bool IsIntegerArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Byte or HeidenhainPlcArea.Word
|
||||
or HeidenhainPlcArea.Dword or HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord;
|
||||
|
||||
/// <summary>
|
||||
/// Parses an address. Never throws; <paramref name="error"/> explains a rejection.
|
||||
/// </summary>
|
||||
/// <param name="text">The authored address.</param>
|
||||
/// <param name="address">The parsed address on success.</param>
|
||||
/// <param name="error">Why the address was rejected.</param>
|
||||
/// <returns>True when the address is valid.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reference's data-path rules: non-blank, NUL-free Latin-1, and short enough that the
|
||||
/// <c>R_DP</c> payload (4 zero bytes + normalised path + NUL) fits the 247-byte legacy payload.
|
||||
/// </summary>
|
||||
/// <param name="path">The data path.</param>
|
||||
/// <param name="error">Why the path was rejected.</param>
|
||||
/// <returns>True when the path is sendable.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reference's datatype-vs-area rules for a <c>PLC:</c> binding: Boolean only for bit areas, String
|
||||
/// only for STRING, and integers wide enough for the native range (never narrowing or wrapping).
|
||||
/// </summary>
|
||||
/// <param name="area">The PLC area.</param>
|
||||
/// <param name="type">The declared data type.</param>
|
||||
/// <returns>Null when acceptable, else the reason.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The normalised data path the <c>R_DP</c> request carries (<c>/</c>→<c>\</c>, <c>"</c>→<c>'</c>).</summary>
|
||||
/// <param name="path">The authored path.</param>
|
||||
/// <returns>The normalised path.</returns>
|
||||
public static string NormalizeDataPath(string path) => path.Replace('/', '\\').Replace('"', '\'');
|
||||
|
||||
/// <summary>Latin-1 encoding used by every LSV2 string.</summary>
|
||||
public static Encoding Latin1 => Encoding.Latin1;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>The three acquisition cadences a Heidenhain tag can ride.</summary>
|
||||
public enum HeidenhainPollGroup
|
||||
{
|
||||
/// <summary>Execution/program state, program, overrides, axes (<c>Polling.StatusMs</c>).</summary>
|
||||
Status,
|
||||
|
||||
/// <summary>Configured PLC / DATA bindings (<c>Polling.CustomMs</c>).</summary>
|
||||
Custom,
|
||||
|
||||
/// <summary>Identity, tool, alarms, timers and capability strings (<c>Polling.SlowMs</c>).</summary>
|
||||
Slow,
|
||||
}
|
||||
|
||||
/// <summary>One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it.</summary>
|
||||
/// <param name="Address">The device-relative address, e.g. <c>Status/ExecutionState</c>.</param>
|
||||
/// <param name="DataType">The OPC UA data type the driver publishes.</param>
|
||||
/// <param name="IsArray">True for the variable-length array leaves (axes, alarms).</param>
|
||||
/// <param name="Group">The poll cadence.</param>
|
||||
/// <param name="Feature">
|
||||
/// The feature the leaf is acquired through (one wire read per feature per batch); for a
|
||||
/// <c>Capabilities/</c> leaf the feature whose state it reports.
|
||||
/// </param>
|
||||
public sealed record HeidenhainCatalogEntry(
|
||||
string Address, DriverDataType DataType, bool IsArray, HeidenhainPollGroup Group, string Feature)
|
||||
{
|
||||
/// <summary>True for a local <c>Capabilities/</c> string, which never causes a wire read.</summary>
|
||||
public bool IsCapability => Address.StartsWith(HeidenhainCatalog.CapabilitiesFolder + "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stable, general-purpose device-relative namespace (ported from the ignitionoee v0.20.3 reference
|
||||
/// <c>HeidenhainTags</c>). 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.
|
||||
/// </summary>
|
||||
public static class HeidenhainCatalog
|
||||
{
|
||||
/// <summary>The capability-string folder.</summary>
|
||||
public const string CapabilitiesFolder = "Capabilities";
|
||||
|
||||
/// <summary>Feature id for the version / identity reply.</summary>
|
||||
public const string IdentityFeature = "Identity";
|
||||
|
||||
/// <summary>Feature id reported for the R_PR memory layout used by PLC bindings.</summary>
|
||||
public const string PlcLayoutFeature = "PlcLayout";
|
||||
|
||||
/// <summary>Feature id reported for the optional DNC login.</summary>
|
||||
public const string DncLoginFeature = "DncLogin";
|
||||
|
||||
/// <summary>Feature id reported for the opt-in DATA login.</summary>
|
||||
public const string DataLoginFeature = "DataLogin";
|
||||
|
||||
/// <summary>Experimental timer leaf names, in selector order (30..34).</summary>
|
||||
public static readonly IReadOnlyList<string> TimerNames =
|
||||
["NcUptime", "MachineUptime", "MachineRuntime", "Spindle0Runtime", "Spindle1Runtime"];
|
||||
|
||||
/// <summary>Feature-state strings published on <c>Capabilities/</c> leaves.</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>Every fixed leaf, value leaves first then the capability strings.</summary>
|
||||
public static IReadOnlyList<HeidenhainCatalogEntry> Entries { get; } = Build();
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, HeidenhainCatalogEntry> ByAddress =
|
||||
Entries.ToDictionary(e => e.Address, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Looks up a fixed leaf by its exact (case-sensitive) address.</summary>
|
||||
/// <param name="address">The address.</param>
|
||||
/// <param name="entry">The entry, when found.</param>
|
||||
/// <returns>True when <paramref name="address"/> is a fixed leaf.</returns>
|
||||
public static bool TryGet(string address, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainCatalogEntry? entry)
|
||||
=> ByAddress.TryGetValue(address, out entry);
|
||||
|
||||
/// <summary>The value features (excluding capability-only features), in catalog order.</summary>
|
||||
public static IReadOnlyList<string> ValueFeatures { get; } =
|
||||
Entries.Where(e => !e.IsCapability).Select(e => e.Feature).Distinct().ToList();
|
||||
|
||||
private static List<HeidenhainCatalogEntry> Build()
|
||||
{
|
||||
var list = new List<HeidenhainCatalogEntry>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The feature id of an experimental timer leaf.</summary>
|
||||
/// <param name="timerName">One of <see cref="TimerNames"/>.</param>
|
||||
/// <returns><c>Timer_<name></c>.</returns>
|
||||
public static string TimerFeature(string timerName) => "Timer_" + timerName;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// Heidenhain LSV2 driver configuration (lmxopcua#803). One driver instance serves N controls; each
|
||||
/// <c>/raw</c> Device is one controller endpoint (<see cref="HeidenhainDeviceOptions"/>), merged in by
|
||||
/// <c>DriverDeviceConfigMerger</c> as the <c>Devices</c> array.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No enum-typed fields, by design.</b> 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.</para>
|
||||
/// <para><b>Read-only by construction.</b> Nothing in this document can enable a write: the driver does not
|
||||
/// implement <c>IWritable</c>, and its outbound LSV2 allowlist admits only logins and read requests.</para>
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainDriverOptions
|
||||
{
|
||||
/// <summary>Default LSV2 TCP port.</summary>
|
||||
public const int DefaultPort = 19000;
|
||||
|
||||
/// <summary>The configured controllers (one per <c>/raw</c> Device).</summary>
|
||||
public IReadOnlyList<HeidenhainDeviceOptions> Devices { get; init; } = [];
|
||||
|
||||
/// <summary>The authored raw tags this driver serves, injected by the deploy artifact.</summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>Connect + whole-response transaction deadline, ms (100–60000). Default 5000.</summary>
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>Credentials and optional-role access.</summary>
|
||||
public HeidenhainAccessOptions Access { get; init; } = new();
|
||||
|
||||
/// <summary>Poll cadences and acquisition limits.</summary>
|
||||
public HeidenhainPollingOptions Polling { get; init; } = new();
|
||||
|
||||
/// <summary>Bounded reconnect backoff after a lost or failed session.</summary>
|
||||
public HeidenhainReconnectOptions Reconnect { get; init; } = new();
|
||||
|
||||
/// <summary>Timeout for the AdminUI Test Connect probe, seconds (1–60). Default 10.</summary>
|
||||
public int ProbeTimeoutSeconds { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>One Heidenhain control endpoint — a <c>/raw</c> Device's <c>DeviceConfig</c>.</summary>
|
||||
public sealed class HeidenhainDeviceOptions
|
||||
{
|
||||
/// <summary>Hostname or IP address of the control. Required; there is no default endpoint.</summary>
|
||||
public string Host { get; init; } = "";
|
||||
|
||||
/// <summary>LSV2 TCP port (1–65535). Default 19000.</summary>
|
||||
public int Port { get; init; } = HeidenhainDriverOptions.DefaultPort;
|
||||
|
||||
/// <summary>The <c>/raw</c> Device name (the RawPath device segment) — the tag routing key.</summary>
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>Optional per-device INSPECT password reference; overrides <see cref="HeidenhainAccessOptions.InspectPassword"/>.</summary>
|
||||
public string? InspectPassword { get; init; }
|
||||
|
||||
/// <summary>Optional per-device DNC password reference; overrides <see cref="HeidenhainAccessOptions.DncPassword"/>.</summary>
|
||||
public string? DncPassword { get; init; }
|
||||
|
||||
/// <summary>Optional per-device DATA password reference; overrides <see cref="HeidenhainAccessOptions.DataPassword"/>.</summary>
|
||||
public string? DataPassword { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access settings. Password fields are <b>references</b>, never literals: blank (no password),
|
||||
/// <c>secret:<name></c> (the node's encrypted secret store) or <c>env:<VARIABLE></c>. A literal is
|
||||
/// refused at parse and at the deploy gate, so a controller password never lands in the config database.
|
||||
/// </summary>
|
||||
public sealed class HeidenhainAccessOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool TelemetryEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool DncLoginEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>Opt-in DATA login, attempted only when a <c>DATA:</c> binding needs it. Default off.</summary>
|
||||
public bool DataEnabled { get; init; }
|
||||
|
||||
/// <summary>INSPECT password reference (blank / <c>secret:</c> / <c>env:</c>).</summary>
|
||||
public string? InspectPassword { get; init; }
|
||||
|
||||
/// <summary>DNC password reference (blank / <c>secret:</c> / <c>env:</c>).</summary>
|
||||
public string? DncPassword { get; init; }
|
||||
|
||||
/// <summary>DATA password reference (blank / <c>secret:</c> / <c>env:</c>).</summary>
|
||||
public string? DataPassword { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Poll cadences (each 100–3600000 ms) and limits.</summary>
|
||||
public sealed class HeidenhainPollingOptions
|
||||
{
|
||||
/// <summary>Status group: execution/program state, program, overrides, axes. Default 1000 ms.</summary>
|
||||
public int StatusMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>Custom group: PLC and DATA bindings. Default 1000 ms.</summary>
|
||||
public int CustomMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms.</summary>
|
||||
public int SlowMs { get; init; } = 10000;
|
||||
|
||||
/// <summary>Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64.</summary>
|
||||
public int MaxAlarms { get; init; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Experimental timer selectors 30–34 (reference-derived; units/semantics unverified on a real control).
|
||||
/// Default <b>off</b>.
|
||||
/// </summary>
|
||||
public bool TimersEnabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Reconnect backoff: doubling from <see cref="InitialDelayMs"/> to <see cref="MaxDelayMs"/>.</summary>
|
||||
public sealed class HeidenhainReconnectOptions
|
||||
{
|
||||
/// <summary>First retry delay, ms (100–600000). Default 1000.</summary>
|
||||
public int InitialDelayMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>Retry delay ceiling, ms (InitialDelayMs–600000). Default 30000.</summary>
|
||||
public int MaxDelayMs { get; init; } = 30000;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// The one parser + validator of the Heidenhain <c>DriverConfig</c> 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.
|
||||
/// </summary>
|
||||
public static class HeidenhainOptionsParser
|
||||
{
|
||||
/// <summary>Prefix of a secret-store password reference.</summary>
|
||||
public const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>Prefix of an environment-variable password reference.</summary>
|
||||
public const string EnvPrefix = "env:";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Deserialises a config document. Throws <see cref="InvalidOperationException"/> on unreadable JSON.</summary>
|
||||
/// <param name="json">The DriverConfig JSON (blank ⇒ defaults).</param>
|
||||
/// <returns>The options.</returns>
|
||||
public static HeidenhainDriverOptions Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new HeidenhainDriverOptions();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<HeidenhainDriverOptions>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates driver-level ranges and every password reference. Device endpoints are validated per device
|
||||
/// by <see cref="ValidateDevice"/> (a driver may be authored before its devices).
|
||||
/// </summary>
|
||||
/// <param name="o">The options.</param>
|
||||
/// <returns>Human-readable errors; empty when valid. Never echoes a credential value.</returns>
|
||||
public static IReadOnlyList<string> Validate(HeidenhainDriverOptions o)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(o);
|
||||
var errors = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Validates one device endpoint.</summary>
|
||||
/// <param name="d">The device.</param>
|
||||
/// <returns>Errors; empty when valid.</returns>
|
||||
public static IReadOnlyList<string> ValidateDevice(HeidenhainDeviceOptions d)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="value"/> is an acceptable password field: blank, <c>secret:<name></c> or
|
||||
/// <c>env:<VARIABLE></c>. A literal password is refused so it never lands in the config database.
|
||||
/// </summary>
|
||||
/// <param name="value">The field value.</param>
|
||||
/// <returns>Whether it is a reference (or blank).</returns>
|
||||
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<string> errors, string field, string? value)
|
||||
{
|
||||
if (!IsCredentialReference(value))
|
||||
errors.Add($"{field} must be blank, 'secret:<name>' or 'env:<VARIABLE>' — a literal password is not stored.");
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>One authored Heidenhain raw tag, resolved.</summary>
|
||||
/// <param name="RawPath">The RawPath identity (driver wire reference).</param>
|
||||
/// <param name="DeviceName">The <c>/raw</c> Device the tag lives under.</param>
|
||||
/// <param name="Address">The parsed address.</param>
|
||||
/// <param name="DataType">The OPC data type the driver publishes.</param>
|
||||
/// <param name="IsArray">Whether the value is a 1-D array.</param>
|
||||
public sealed record HeidenhainTagDefinition(
|
||||
string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray)
|
||||
{
|
||||
/// <summary>The poll cadence this tag rides.</summary>
|
||||
public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard
|
||||
? Address.Catalog!.Group
|
||||
: HeidenhainPollGroup.Custom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single authority on a Heidenhain raw tag's <c>TagConfig</c>: consumed by the driver's table build,
|
||||
/// the deploy-time gate (<see cref="Inspect"/>) and the AdminUI typed editor's <c>Validate()</c>, so the
|
||||
/// editor, the deploy gate and the runtime accept exactly the same blobs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Shape: <c>{"address": "Status/ExecutionState"}</c>,
|
||||
/// <c>{"address": "PLC:MARKER:32", "dataType": "Boolean"}</c>,
|
||||
/// <c>{"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}</c>. Array leaves
|
||||
/// (<c>Axes/*</c>, <c>Alarms/Classes|Groups|Numbers|Messages</c>) must carry <c>"isArray": true</c> so the
|
||||
/// node's rank matches what the driver publishes; every other address must not.</para>
|
||||
/// <para><b>Never a write flag.</b> There is no <c>writable</c> key: the driver is read-only and the deploy
|
||||
/// gate refuses a ReadWrite access level on any Heidenhain tag.</para>
|
||||
/// <para><b>Data types are parsed by NAME only.</b> A digits-only value (a numerically-serialized enum) is
|
||||
/// rejected rather than mapped to the ordinal's member.</para>
|
||||
/// </remarks>
|
||||
public static class HeidenhainTagDefinitionFactory
|
||||
{
|
||||
/// <summary>Parses one tag. Never throws.</summary>
|
||||
/// <param name="rawPath">The tag's RawPath.</param>
|
||||
/// <param name="deviceName">The tag's Device name.</param>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <param name="definition">The resolved definition on success.</param>
|
||||
/// <param name="error">Why the blob was rejected.</param>
|
||||
/// <returns>True when the driver will serve this tag.</returns>
|
||||
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<DriverDataType>())}.";
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection: empty when <see cref="TryFromTagConfig"/> accepts the blob (never stricter),
|
||||
/// otherwise one warning explaining the rejection. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string? tagConfig)
|
||||
{
|
||||
if (TryFromTagConfig("inspect", "", tagConfig, out _, out var error)) return [];
|
||||
return [$"Heidenhain TagConfig rejected: {error} The driver will skip this tag (BadNodeIdUnknown)."];
|
||||
}
|
||||
|
||||
/// <summary>Parses a data type by NAME; digits-only text is refused (the numeric-enum trap).</summary>
|
||||
/// <param name="raw">The authored text.</param>
|
||||
/// <param name="parsed">The parsed type.</param>
|
||||
/// <returns>True when <paramref name="raw"/> names a defined member.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
<!-- Pure options records, the fixed namespace catalog and the TagConfig address grammar, shared by the
|
||||
driver, the deploy gate and the AdminUI typed editor. NO PackageReference: only the zero-dependency
|
||||
Core.Abstractions leaf, mirroring Driver.FOCAS.Contracts / Driver.MTConnect.Contracts. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A binding's value is outside its advertised region / range.</summary>
|
||||
public sealed class HeidenhainRangeException(string message) : Exception(message);
|
||||
|
||||
/// <summary>A value cannot be represented in the binding's declared type without truncation or wrap.</summary>
|
||||
public sealed class HeidenhainTypeMismatchException(string message) : Exception(message);
|
||||
|
||||
/// <summary>An absolute PLC memory range.</summary>
|
||||
/// <param name="Address">Absolute address.</param>
|
||||
/// <param name="Bytes">Byte count.</param>
|
||||
public readonly record struct HeidenhainMemoryRange(long Address, int Bytes);
|
||||
|
||||
/// <summary>
|
||||
/// The reference's PLC/DATA binding rules (port of <c>HeidenhainBindings</c>): element indexes, byte
|
||||
/// capacities, exact lengths, strict little-endian decodes and non-truncating coercion.
|
||||
/// </summary>
|
||||
public static class HeidenhainBindingCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <param name="index">The element index.</param>
|
||||
/// <param name="layout">The control's layout.</param>
|
||||
/// <returns>The absolute range.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Decodes PLC bytes for an area and coerces to the declared type.</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <param name="bytes">The bytes read.</param>
|
||||
/// <param name="type">The declared type.</param>
|
||||
/// <returns>The value.</returns>
|
||||
public static object DecodePlc(HeidenhainPlcArea area, ReadOnlySpan<byte> 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<byte> bytes, int length, ReadOnlySpanFunc decode)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, length);
|
||||
return decode(bytes);
|
||||
}
|
||||
|
||||
private delegate object ReadOnlySpanFunc(ReadOnlySpan<byte> bytes);
|
||||
|
||||
/// <summary>
|
||||
/// Strict coercion: same-kind Boolean/String pass; numbers convert only exactly (no fractional truncation,
|
||||
/// no wrap, finite only). Anything else is <see cref="HeidenhainTypeMismatchException"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">The native value.</param>
|
||||
/// <param name="type">The declared type.</param>
|
||||
/// <returns>The coerced value.</returns>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves password references on demand (each login), so plaintext is never held in options or session
|
||||
/// state. Fail-closed: a <c>secret:</c> 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.
|
||||
/// </summary>
|
||||
internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<string, string?>? environment = null)
|
||||
{
|
||||
private readonly Func<string, string?> _environment = environment ?? Environment.GetEnvironmentVariable;
|
||||
|
||||
/// <summary>The password for a role on a device (device override, then driver-level).</summary>
|
||||
public async ValueTask<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>Resolves one reference.</summary>
|
||||
public async ValueTask<string> 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).");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Connection state of one controller.</summary>
|
||||
public enum HeidenhainConnectionState
|
||||
{
|
||||
/// <summary>Not started (no subscription/read yet) or between generations.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>Opening a session.</summary>
|
||||
Connecting,
|
||||
|
||||
/// <summary>A session is established and polling.</summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>The last connect failed or the session was lost; waiting out the backoff.</summary>
|
||||
Backoff,
|
||||
|
||||
/// <summary>Stopped (driver shutdown).</summary>
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// <summary>Builds the client for an endpoint (test seam).</summary>
|
||||
internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>HeidenhainAcquisition</c> + <c>HeidenhainDevice</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Generation guard.</b> Each established session is a new generation. Publication checks the
|
||||
/// generation under <see cref="_publication"/>, 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.</para>
|
||||
/// <para><b>Health vs. per-tag quality.</b> 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 (<see cref="DataHealth"/>).</para>
|
||||
/// </remarks>
|
||||
internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
|
||||
{
|
||||
private readonly HeidenhainDeviceOptions _device;
|
||||
private readonly HeidenhainDriverOptions _options;
|
||||
private readonly IReadOnlyList<HeidenhainTagDefinition> _tags;
|
||||
private readonly IReadOnlyDictionary<HeidenhainPollGroup, IReadOnlyList<HeidenhainTagDefinition>> _groups;
|
||||
private readonly HeidenhainSession.PasswordSource _passwords;
|
||||
private readonly Action<string, DataValueSnapshot> _publish;
|
||||
private readonly Action<HeidenhainDeviceRuntime, HeidenhainConnectionState, HeidenhainConnectionState> _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<HeidenhainPollGroup, bool> _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<HeidenhainTagDefinition> tags,
|
||||
HeidenhainSession.PasswordSource passwords,
|
||||
Action<string, DataValueSnapshot> publish,
|
||||
Action<HeidenhainDeviceRuntime, HeidenhainConnectionState, HeidenhainConnectionState> 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<HeidenhainTagDefinition>)g.ToList());
|
||||
}
|
||||
|
||||
/// <summary>The <c>host:port</c> key used for host status and per-call host resolution.</summary>
|
||||
public string HostName => $"{_device.Host}:{_device.Port}";
|
||||
|
||||
/// <summary>The <c>/raw</c> Device name.</summary>
|
||||
public string DeviceName => _device.DeviceName ?? "";
|
||||
|
||||
/// <summary>The device's authored tags.</summary>
|
||||
public IReadOnlyList<HeidenhainTagDefinition> Tags => _tags;
|
||||
|
||||
/// <summary>Current connection state.</summary>
|
||||
public HeidenhainConnectionState State { get { lock (_publication) return _state; } }
|
||||
|
||||
/// <summary>When <see cref="State"/> last changed.</summary>
|
||||
public DateTime StateChangedUtc { get { lock (_publication) return _stateChangedUtc; } }
|
||||
|
||||
/// <summary>The last time a batch completed with the session alive (liveness evidence for the watchdog).</summary>
|
||||
public DateTime? LastSuccessUtc { get { lock (_publication) return _lastSuccessUtc; } }
|
||||
|
||||
/// <summary>The last connect/transport error (never a credential).</summary>
|
||||
public string? LastError { get { lock (_publication) return _lastError; } }
|
||||
|
||||
/// <summary>The reference's poll-group aggregate for this generation: Good / Partial / Lost / Idle.</summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The identity of the current session's control, when connected.</summary>
|
||||
public string? ControllerModel { get { lock (_publication) return _session?.Identity?.Model; } }
|
||||
|
||||
/// <summary>Starts the acquisition loop (idempotent).</summary>
|
||||
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, TimeSpan>
|
||||
{
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Publishes one batch iff its generation is still current. False when the session is lost/retired.</summary>
|
||||
private bool Publish(long generation, HeidenhainSession session, HeidenhainPollGroup group,
|
||||
IReadOnlyList<HeidenhainTagDefinition> tags, IReadOnlyList<HeidenhainSample> 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);
|
||||
}
|
||||
|
||||
/// <summary>Retires the session and invalidates every remote value (capabilities report CommunicationLost).</summary>
|
||||
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"); }
|
||||
}
|
||||
|
||||
/// <summary>Stops the loop: closes the session first (unblocks a read), cancels, joins ≤ 5 s, invalidates.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>/raw</c> Device.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Read-only at three layers.</b> (1) This type does not implement <see cref="IWritable"/>, 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
|
||||
/// <see cref="Lsv2ReadOnlyPolicy"/> admits only logins and read requests, payload-checked, at the last step
|
||||
/// before bytes are written.</para>
|
||||
/// <para><b>Cached reads.</b> Acquisition runs on one background loop per control at the configured poll
|
||||
/// cadences; <see cref="ReadAsync"/> 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 <see cref="SubscribeAsync"/> / <see cref="ReadAsync"/>, not at
|
||||
/// <see cref="InitializeAsync"/> — a throwaway browse instance (the static catalog needs no connection)
|
||||
/// therefore never opens a session to a production control.</para>
|
||||
/// <para><b>Comms loss.</b> A lost session publishes <c>BadNoCommunication</c> on every remote value of that
|
||||
/// control at once (capability strings read <c>CommunicationLost</c>), marks the driver Degraded with
|
||||
/// <see cref="DriverHealth.LastSuccessfulRead"/> 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).</para>
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe,
|
||||
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Most custom (PLC/DATA) bindings one control may carry (reference limit).</summary>
|
||||
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<string, DataValueSnapshot> _cache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<long, Subscription> _subscriptions = new();
|
||||
private HeidenhainDriverOptions _options;
|
||||
private IReadOnlyDictionary<string, HeidenhainDeviceRuntime> _byRawPath = new Dictionary<string, HeidenhainDeviceRuntime>();
|
||||
private IReadOnlyList<HeidenhainDeviceRuntime> _devices = [];
|
||||
private volatile bool _initialized;
|
||||
private long _nextSubscriptionId;
|
||||
private DriverState _state = DriverState.Unknown;
|
||||
private string? _initError;
|
||||
|
||||
/// <summary>Production constructor.</summary>
|
||||
/// <param name="options">The parsed options.</param>
|
||||
/// <param name="driverInstanceId">The driver instance id.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
/// <param name="secretResolver">Resolver for <c>secret:</c> password references.</param>
|
||||
public HeidenhainDriver(HeidenhainDriverOptions options, string driverInstanceId,
|
||||
ILogger<HeidenhainDriver>? 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Heidenhain;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <summary>Parses + validates a config document (the one authority the factory and the driver share).</summary>
|
||||
/// <param name="driverConfigJson">The config JSON.</param>
|
||||
/// <returns>The options.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string>();
|
||||
var perDevice = new Dictionary<HeidenhainDeviceOptions, List<HeidenhainTagDefinition>>();
|
||||
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<HeidenhainDeviceRuntime>();
|
||||
var byPath = new Dictionary<string, HeidenhainDeviceRuntime>(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<HeidenhainDeviceOptions> 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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
|
||||
InitializeAsync(driverConfigJson, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, HeidenhainDeviceRuntime>();
|
||||
foreach (var d in devices) await d.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(IReadOnlyList<string> 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<IReadOnlyList<DataValueSnapshot>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(IReadOnlyList<string> 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<ISubscriptionHandle>(sub);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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); }
|
||||
}
|
||||
|
||||
/// <summary>Value equality including element-wise array comparison (the (value, status) dedup).</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
_devices.Select(d => new HostConnectivityStatus(d.HostName, ToHostState(d.State), d.StateChangedUtc)).ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ResolveHost(string fullReference) =>
|
||||
_byRawPath.TryGetValue(fullReference, out var d) ? d.HostName : _driverInstanceId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport? GetSkippedTags() => _skipped.Snapshot();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, string> GetDiagnostics()
|
||||
{
|
||||
var bag = new Dictionary<string, string>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folders = new Dictionary<string, IAddressSpaceBuilder>(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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
private sealed class Subscription(long id, HashSet<string> references) : ISubscriptionHandle
|
||||
{
|
||||
public long Id { get; } = id;
|
||||
public HashSet<string> References { get; } = references;
|
||||
public string DiagnosticId => $"heidenhain-sub-{Id}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Factory registration for <see cref="HeidenhainDriver"/>. Construction opens nothing (the discovery browser
|
||||
/// builds throwaway instances), there is exactly one config parser (<see cref="HeidenhainDriver.ParseOptions"/>),
|
||||
/// and the instance is returned as its concrete type so every optional capability is dispatched — and,
|
||||
/// because it implements no <see cref="IWritable"/>, no write is.
|
||||
/// </summary>
|
||||
public static class HeidenhainDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>The <c>DriverInstance.DriverType</c> this factory answers to.</summary>
|
||||
public const string DriverTypeName = DriverTypeNames.Heidenhain;
|
||||
|
||||
/// <summary>Registers the factory.</summary>
|
||||
/// <param name="registry">The registry.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <param name="secretResolver">Resolver for <c>secret:</c> password references.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Creates an instance from a config document.</summary>
|
||||
/// <param name="driverInstanceId">The instance id.</param>
|
||||
/// <param name="driverConfigJson">The config JSON.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <param name="secretResolver">Optional secret resolver.</param>
|
||||
/// <returns>The driver.</returns>
|
||||
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<HeidenhainDriver>(), secretResolver: secretResolver);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered on admin nodes as well as driver nodes (the Test-Connect singleton is admin-pinned). The
|
||||
/// resolver is optional: without one a <c>secret:</c> INSPECT password fails closed with a clear message.
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null) : IDriverProbe
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Heidenhain;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>One acquired leaf: value (null when Bad) + status.</summary>
|
||||
/// <param name="Value">The value, or null.</param>
|
||||
/// <param name="Status">The OPC UA status code.</param>
|
||||
public readonly record struct HeidenhainSample(object? Value, uint Status);
|
||||
|
||||
/// <summary>What a session needs from the driver configuration.</summary>
|
||||
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<HeidenhainTagDefinition> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generation-local read policy (port of the reference <c>HeidenhainSession</c>): 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.
|
||||
/// </summary>
|
||||
internal sealed class HeidenhainSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Resolves a role's password on demand; plaintext is never retained in session state.</summary>
|
||||
public delegate ValueTask<string> 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<string, string> _capabilities = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, uint> _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;
|
||||
}
|
||||
|
||||
/// <summary>True once a transport failure ended this session.</summary>
|
||||
public bool CommunicationLost => _lost || _client.IsClosed;
|
||||
|
||||
/// <summary>The identity read at initialize.</summary>
|
||||
public Lsv2Versions? Identity => _identity;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<uint> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>BadNoCommunication</c> and
|
||||
/// <see cref="CommunicationLost"/> becomes true.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<HeidenhainSample>> ReadBatchAsync(IReadOnlyList<HeidenhainTagDefinition> tags, CancellationToken ct)
|
||||
{
|
||||
var outcomes = new Dictionary<string, Outcome>(StringComparer.Ordinal);
|
||||
var wire = new Dictionary<string, Raw>(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;
|
||||
|
||||
/// <summary>The current capability string for a feature.</summary>
|
||||
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<Outcome> AcquireAsync(HeidenhainTagDefinition tag, Dictionary<string, Raw> 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<long> 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<object> CustomAsync(HeidenhainTagDefinition tag, Dictionary<string, Raw> 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<string, Raw> 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<string, Raw> 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<List<Lsv2Alarm>> AlarmsAsync(CancellationToken ct)
|
||||
{
|
||||
var records = new List<Lsv2Alarm>();
|
||||
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<Lsv2Alarm> 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<byte[]> WireAsync(Dictionary<string, Raw> wire, string key, Func<CancellationToken, Task<byte[]>> 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;
|
||||
}
|
||||
|
||||
/// <summary>Closes the client immediately (interrupts a blocked read without waiting).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes this driver publishes (canonical <c>Opc.Ua.StatusCodes</c> numerics, pinned by
|
||||
/// <c>HeidenhainStatusTests</c>), with the reference's failure interpretation.
|
||||
/// </summary>
|
||||
public static class HeidenhainStatus
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
|
||||
/// <summary>Transport lost — every remote leaf, immediately.</summary>
|
||||
public const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>Refusal 22/24, disabled feature, or a feature absent from the reply (8-byte tool geometry).</summary>
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>Refusal 23/49.</summary>
|
||||
public const uint BadUserAccessDenied = 0x801F0000u;
|
||||
|
||||
/// <summary>Malformed complete record.</summary>
|
||||
public const uint BadDecodingError = 0x80070000u;
|
||||
|
||||
/// <summary>Binding type mismatch.</summary>
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Out-of-region binding, or alarm-list overflow.</summary>
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
|
||||
/// <summary>Any other controller refusal (retried next poll).</summary>
|
||||
public const uint BadUnexpectedError = 0x80010000u;
|
||||
|
||||
/// <summary>A RawPath this driver has no authored tag for.</summary>
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary>Authored but not yet acquired.</summary>
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>Maps a controller refusal code to a value status (22/24 unsupported, 23/49 denied, else unexpected).</summary>
|
||||
/// <param name="code">The refusal code.</param>
|
||||
/// <returns>The status.</returns>
|
||||
public static uint FromRefusal(int code) => code switch
|
||||
{
|
||||
22 or 24 => BadNotSupported,
|
||||
23 or 49 => BadUserAccessDenied,
|
||||
_ => BadUnexpectedError,
|
||||
};
|
||||
|
||||
/// <summary>True for the stable refusals that may be suppressed for the rest of a session.</summary>
|
||||
/// <param name="status">The status.</param>
|
||||
/// <returns>Whether it is stable.</returns>
|
||||
public static bool IsStable(uint status) => status is BadNotSupported or BadUserAccessDenied;
|
||||
|
||||
/// <summary>The capability state for a failure status.</summary>
|
||||
/// <param name="status">The status.</param>
|
||||
/// <returns>The state string.</returns>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>
|
||||
/// One serialized native LSV2 TCP session (port of the reference <c>Lsv2Client</c>). Exactly one transaction
|
||||
/// is on the wire at a time; <see cref="Close"/> never waits on the transaction gate, so it always interrupts a
|
||||
/// blocked read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The public surface is the read-only surface.</b> 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
|
||||
/// <see cref="Lsv2Codec.Frame"/>, which applies <see cref="Lsv2ReadOnlyPolicy.Check"/> before any byte is
|
||||
/// written.</para>
|
||||
/// <para><b>Framing discipline.</b> 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 <c>T_ER</c>/<c>T_BD</c> refusal leaves the stream synchronised
|
||||
/// and surfaces as <see cref="Lsv2RefusalException"/> without closing.</para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>Creates an unopened client.</summary>
|
||||
/// <param name="host">The control host.</param>
|
||||
/// <param name="port">The LSV2 port.</param>
|
||||
/// <param name="timeout">Connect + per-transaction deadline.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True once the client has been closed (explicitly or by a transport failure).</summary>
|
||||
public bool IsClosed => _closed;
|
||||
|
||||
/// <summary>Total request frames written (diagnostics/tests).</summary>
|
||||
public long RequestsSent => Interlocked.Read(ref _requestsSent);
|
||||
|
||||
private long _requestsSent;
|
||||
|
||||
/// <summary>Connects, bounded by the configured timeout.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>A_LG</c> → <c>T_OK</c>. Only INSPECT/DNC/DATA are accepted by the policy.</summary>
|
||||
/// <param name="role">The role.</param>
|
||||
/// <param name="password">The resolved password, or empty.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
public Task LoginAsync(string role, string password, CancellationToken ct) =>
|
||||
ExchangeAsync("A_LG", Lsv2ReadOnlyPolicy.LoginPayload(role, password), "T_OK", ct);
|
||||
|
||||
/// <summary><c>R_VR</c> → <c>S_VR</c> combined version record.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadVersionAsync(CancellationToken ct) => ExchangeAsync("R_VR", [], "S_VR", ct);
|
||||
|
||||
/// <summary><c>R_PR</c> → <c>S_PR</c> system/memory-layout record.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadParametersAsync(CancellationToken ct) => ExchangeAsync("R_PR", [], "S_PR", ct);
|
||||
|
||||
/// <summary><c>R_RI</c> → <c>S_RI</c> runtime information for an allowlisted selector.</summary>
|
||||
/// <param name="selector">The selector.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadRuntimeAsync(int selector, CancellationToken ct) =>
|
||||
ExchangeAsync("R_RI", Lsv2ReadOnlyPolicy.RuntimePayload(selector), "S_RI", ct);
|
||||
|
||||
/// <summary><c>R_MB</c> → <c>S_MB</c> PLC memory bytes.</summary>
|
||||
/// <param name="address">Absolute address.</param>
|
||||
/// <param name="count">Byte count (1–247).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw bytes.</returns>
|
||||
public Task<byte[]> ReadMemoryAsync(long address, int count, CancellationToken ct) =>
|
||||
ExchangeAsync("R_MB", Lsv2ReadOnlyPolicy.MemoryPayload(address, count), "S_MB", ct);
|
||||
|
||||
/// <summary><c>R_DP</c> → <c>S_DP</c> data-path value.</summary>
|
||||
/// <param name="path">The data path.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The raw record.</returns>
|
||||
public Task<byte[]> ReadDataPathAsync(string path, CancellationToken ct) =>
|
||||
ExchangeAsync("R_DP", Lsv2ReadOnlyPolicy.DataPathPayload(path), "S_DP", ct);
|
||||
|
||||
/// <summary>
|
||||
/// The single transaction primitive. <c>internal</c> (not public) and still policy-checked: the frame is
|
||||
/// built — and refused — before the gate is taken or the socket is touched.
|
||||
/// </summary>
|
||||
internal async Task<byte[]> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes the socket without waiting for an in-flight transaction (which then fails).</summary>
|
||||
public void Close()
|
||||
{
|
||||
_closed = true;
|
||||
var socket = _socket;
|
||||
try { _stream?.Dispose(); } catch { /* already unavailable */ }
|
||||
try { socket?.Dispose(); } catch { /* already unavailable */ }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Close();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>A complete, malformed controller record (wrong length, missing terminator, NaN, …).</summary>
|
||||
public sealed class Lsv2DecodeException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// A complete controller refusal frame (<c>T_ER</c>/<c>T_BD</c>). Unlike a transport failure it leaves the
|
||||
/// framing synchronised, so the session stays usable.
|
||||
/// </summary>
|
||||
public sealed class Lsv2RefusalException(int type, int code)
|
||||
: Exception($"LSV2 controller refusal type={type} code={code}")
|
||||
{
|
||||
/// <summary>The refusal type byte.</summary>
|
||||
public int Type { get; } = type;
|
||||
|
||||
/// <summary>The refusal code byte (22/24 unsupported, 23/49 access denied, 57 no next error, …).</summary>
|
||||
public int Code { get; } = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A transport failure (EOF, deadline, oversized/unexpected frame, socket error). The client has been closed;
|
||||
/// the session is over.
|
||||
/// </summary>
|
||||
public sealed class Lsv2TransportException(string message, Exception? inner = null) : IOException(message, inner);
|
||||
|
||||
/// <summary>Legacy LSV2 framing and Latin-1 primitives. No buffer negotiation is performed.</summary>
|
||||
public static class Lsv2Codec
|
||||
{
|
||||
/// <summary>Frame header size: uint32be payload length + 4 ASCII command bytes.</summary>
|
||||
public const int HeaderSize = 8;
|
||||
|
||||
/// <summary>Legacy maximum payload (255-byte frame − header).</summary>
|
||||
public const int MaxPayload = HeidenhainAddress.MaxPayload;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="command">The command.</param>
|
||||
/// <param name="payload">The payload.</param>
|
||||
/// <returns>The frame bytes.</returns>
|
||||
public static byte[] Frame(string command, ReadOnlySpan<byte> 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;
|
||||
}
|
||||
|
||||
/// <summary>NUL-terminated Latin-1 encoding; refuses embedded NULs and non-Latin-1 text.</summary>
|
||||
/// <param name="value">The text.</param>
|
||||
/// <returns>The bytes, NUL included.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Requires an exact record length.</summary>
|
||||
/// <param name="bytes">The record.</param>
|
||||
/// <param name="expected">The expected length.</param>
|
||||
public static void Length(ReadOnlySpan<byte> bytes, int expected)
|
||||
{
|
||||
if (bytes.Length != expected) throw new Lsv2DecodeException("Invalid LSV2 record length.");
|
||||
}
|
||||
|
||||
/// <summary>Decodes a single NUL-terminated Latin-1 string that may only be followed by NUL padding.</summary>
|
||||
/// <param name="bytes">The record.</param>
|
||||
/// <returns>The text.</returns>
|
||||
public static string Text(ReadOnlySpan<byte> 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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>Controller identity from <c>R_VR</c>.</summary>
|
||||
public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options);
|
||||
|
||||
/// <summary>Program stack from <c>R_RI 24</c>.</summary>
|
||||
public sealed record Lsv2Program(long Line, string Main, string Current);
|
||||
|
||||
/// <summary>Tool from <c>R_RI 51</c>; geometry is null on the 8-byte identity-only reply.</summary>
|
||||
public sealed record Lsv2Tool(long Number, int Index, int Axis, double? Length, double? Radius);
|
||||
|
||||
/// <summary>One alarm record from <c>R_RI 27/28</c>.</summary>
|
||||
public sealed record Lsv2Alarm(int ErrorClass, int Group, int Number, string Message);
|
||||
|
||||
/// <summary>Aligned axis names and positions from <c>R_RI 22</c> (controller units, no inference).</summary>
|
||||
public sealed record Lsv2Axes(string[] Names, double[] Positions);
|
||||
|
||||
/// <summary>An advertised PLC region: base address, capacity in bytes, native element width.</summary>
|
||||
public sealed record Lsv2Region(long Base, long Bytes, int Width);
|
||||
|
||||
/// <summary>The <c>R_PR</c> PLC memory layout.</summary>
|
||||
public sealed record Lsv2Layout(IReadOnlyDictionary<HeidenhainPlcArea, Lsv2Region> Regions)
|
||||
{
|
||||
/// <summary>The region for an area, or null.</summary>
|
||||
/// <param name="area">The area.</param>
|
||||
/// <returns>The region.</returns>
|
||||
public Lsv2Region? Region(HeidenhainPlcArea area) => Regions.TryGetValue(area, out var r) ? r : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure reference-derived decoders (port of <c>Lsv2Data</c>). Malformed data is never repaired into a Good
|
||||
/// value: every rule throws <see cref="Lsv2DecodeException"/>.
|
||||
/// </summary>
|
||||
public static class Lsv2Data
|
||||
{
|
||||
/// <summary>Exactly four or five NUL-terminated fields; an optional SPLC 4th field is skipped; empty final options are preserved.</summary>
|
||||
public static Lsv2Versions Versions(ReadOnlySpan<byte> 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]);
|
||||
}
|
||||
|
||||
/// <summary>Exactly two bytes, uint16be.</summary>
|
||||
public static int U16(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, 2);
|
||||
return BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||
}
|
||||
|
||||
/// <summary>Exactly four bytes, uint32be.</summary>
|
||||
public static long U32(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, 4);
|
||||
return BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||
}
|
||||
|
||||
/// <summary>uint32be line, main NUL, current NUL, optional NUL padding only.</summary>
|
||||
public static Lsv2Program Program(ReadOnlySpan<byte> 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]);
|
||||
}
|
||||
|
||||
/// <summary>Three uint32be hundredths of percent: feed, spindle, rapid.</summary>
|
||||
public static double[] Overrides(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Lsv2Codec.Length(bytes, 12);
|
||||
return
|
||||
[
|
||||
BinaryPrimitives.ReadUInt32BigEndian(bytes) / 100.0,
|
||||
BinaryPrimitives.ReadUInt32BigEndian(bytes[4..]) / 100.0,
|
||||
BinaryPrimitives.ReadUInt32BigEndian(bytes[8..]) / 100.0,
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>8-byte identity-only or 24-byte reply with two float64le geometry values.</summary>
|
||||
public static Lsv2Tool Tool(ReadOnlySpan<byte> 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);
|
||||
}
|
||||
|
||||
/// <summary>uint16be class, uint16be group, int32be number, terminated text.</summary>
|
||||
public static Lsv2Alarm Alarm(ReadOnlySpan<byte> 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..]));
|
||||
}
|
||||
|
||||
/// <summary>Opaque byte, uint8 count, count numeric strings, count unique non-blank names.</summary>
|
||||
public static Lsv2Axes Axes(ReadOnlySpan<byte> 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<string>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>R_PR</c>: 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.
|
||||
/// </summary>
|
||||
public static Lsv2Layout Layout(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 120 && bytes.Length != 124) throw Bad();
|
||||
var regions = new Dictionary<HeidenhainPlcArea, Lsv2Region>();
|
||||
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<HeidenhainPlcArea, Lsv2Region> regions)
|
||||
{
|
||||
// Same-base BYTE/WORD/DWORD views intentionally share an area, not a boundary.
|
||||
var starts = new SortedSet<long>(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<long?>().FirstOrDefault();
|
||||
if (next is { } n && n - region.Base < region.Bytes)
|
||||
regions[area] = region with { Bytes = n - region.Base };
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(Dictionary<HeidenhainPlcArea, Lsv2Region> r, ReadOnlySpan<byte> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>R_DP</c> 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.
|
||||
/// </summary>
|
||||
public static object DataPath(ReadOnlySpan<byte> 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<string> Strings(ReadOnlySpan<byte> bytes, int offset)
|
||||
{
|
||||
if (offset > bytes.Length || (offset < bytes.Length && bytes[^1] != 0)) throw Bad();
|
||||
var result = new List<string>();
|
||||
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.");
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
/// <summary>A request the read-only policy refused. Raised BEFORE any byte reaches the socket.</summary>
|
||||
public sealed class Lsv2PolicyException(string message) : InvalidOperationException(message);
|
||||
|
||||
/// <summary>
|
||||
/// The payload-aware, final outbound gate (port of the reference <c>Lsv2ReadOnly</c>). There is deliberately
|
||||
/// no unrestricted mode, no configuration knob and no public raw-telegram API: every frame
|
||||
/// <see cref="Lsv2Codec.Frame"/> builds passes <see cref="Check"/> immediately before it is written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The only telegrams this driver can emit:</para>
|
||||
/// <list type="table">
|
||||
/// <item><term><c>A_LG</c></term><description>role ∈ {INSPECT, DNC, DATA}, NUL; optional NUL-free Latin-1 password, NUL.</description></item>
|
||||
/// <item><term><c>R_VR</c>, <c>R_PR</c></term><description>empty payload.</description></item>
|
||||
/// <item><term><c>R_RI</c></term><description>exactly a uint16be selector ∈ {22–28, 30–34, 51}.</description></item>
|
||||
/// <item><term><c>R_MB</c></term><description>uint32be address + uint8 count, 1 ≤ count ≤ 247, no 32-bit wrap.</description></item>
|
||||
/// <item><term><c>R_DP</c></term><description>four zero bytes + NUL-free path + NUL.</description></item>
|
||||
/// </list>
|
||||
/// <para>Everything else — every <c>C_*</c> (file / PLC / parameter / buffer-size writes), <c>A_LO</c>,
|
||||
/// <c>R_MC</c>/<c>R_FL</c> 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.</para>
|
||||
/// </remarks>
|
||||
public static class Lsv2ReadOnlyPolicy
|
||||
{
|
||||
/// <summary>The read-only roles a login may request.</summary>
|
||||
public static readonly IReadOnlySet<string> Roles = new HashSet<string>(StringComparer.Ordinal) { "INSPECT", "DNC", "DATA" };
|
||||
|
||||
/// <summary>The runtime-information selectors a <c>R_RI</c> may carry.</summary>
|
||||
public static readonly IReadOnlySet<int> Selectors = new HashSet<int> { 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51 };
|
||||
|
||||
/// <summary>The permitted request commands.</summary>
|
||||
public static readonly IReadOnlySet<string> Commands = new HashSet<string>(StringComparer.Ordinal)
|
||||
{ "A_LG", "R_VR", "R_PR", "R_RI", "R_MB", "R_DP" };
|
||||
|
||||
/// <summary>Builds a validated login payload.</summary>
|
||||
/// <param name="role">INSPECT, DNC or DATA.</param>
|
||||
/// <param name="password">The resolved password, or empty.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Builds a validated data-path payload (reference slash normalisation applied).</summary>
|
||||
/// <param name="path">The authored data path.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Builds a validated runtime-information payload.</summary>
|
||||
/// <param name="selector">The selector.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Builds a validated memory-read payload.</summary>
|
||||
/// <param name="address">Absolute address.</param>
|
||||
/// <param name="count">Byte count.</param>
|
||||
/// <returns>The payload.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The final gate: throws <see cref="Lsv2PolicyException"/> unless the command AND its payload are on the
|
||||
/// allowlist.
|
||||
/// </summary>
|
||||
/// <param name="command">The four-character command.</param>
|
||||
/// <param name="payload">The payload.</param>
|
||||
public static void Check(string? command, ReadOnlySpan<byte> 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<byte> 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<byte> 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));
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Heidenhain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- No vendor SDK, no Java/Python runtime: the LSV2 transport is a managed TCP client (System.Net.Sockets).
|
||||
Secrets.Abstractions only for the ISecretResolver seam that resolves secret: password references. -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
@@ -195,6 +196,11 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());
|
||||
// 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<IDriverProbe, HeidenhainProbe>(
|
||||
sp => new HeidenhainProbe(sp.GetService<ISecretResolver>())));
|
||||
|
||||
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);
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
|
||||
Reference in New Issue
Block a user