Merge branch 'master' into b626-opcuaclient
This commit is contained in:
@@ -88,7 +88,7 @@ public sealed class WriteCommand : S7CommandBase
|
||||
internal static object ParseValue(string raw, S7DataType type)
|
||||
{
|
||||
if (type == S7DataType.Bool) return ParseBool(raw);
|
||||
if (type == S7DataType.String) return raw;
|
||||
if (type is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar) return raw;
|
||||
try
|
||||
{
|
||||
return type switch
|
||||
@@ -102,7 +102,11 @@ public sealed class WriteCommand : S7CommandBase
|
||||
S7DataType.UInt64 => (object)ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Float32 => (object)float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Float64 => (object)double.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.DateTime => (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
|
||||
S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date
|
||||
=> (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
|
||||
// TOD / TIME / S5TIME are Int32 milliseconds on the OPC UA side.
|
||||
S7DataType.Tod or S7DataType.Time or S7DataType.S5Time
|
||||
=> (object)long.Parse(raw, CultureInfo.InvariantCulture),
|
||||
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,10 +106,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
|
||||
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
|
||||
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||
private volatile Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||
// Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order —
|
||||
// the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths.
|
||||
private readonly List<AbCipTagDefinition> _declaredTags = new();
|
||||
private volatile List<AbCipTagDefinition> _declaredTags = new();
|
||||
|
||||
// Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a
|
||||
// single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback
|
||||
@@ -335,8 +335,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public string DriverType => "AbCip";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
||||
// runs it with no teardown first). Release the previous device states' probe loops + handles before
|
||||
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
|
||||
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
_logger.LogInformation(
|
||||
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
|
||||
@@ -373,6 +377,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
|
||||
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
var builtTagsByRawPath = new Dictionary<string, AbCipTagDefinition>(StringComparer.Ordinal);
|
||||
var builtDeclaredTags = new List<AbCipTagDefinition>();
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
|
||||
@@ -387,13 +393,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// Duplicate-key check: a collision means two authored tags share the same RawPath.
|
||||
// Fail fast at init time with a diagnostic rather than silently clobbering.
|
||||
if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag))
|
||||
if (builtTagsByRawPath.TryGetValue(tag.Name, out var existingTag))
|
||||
throw new InvalidOperationException(
|
||||
$"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
|
||||
$"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " +
|
||||
$"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate.");
|
||||
_tagsByRawPath[tag.Name] = tag;
|
||||
_declaredTags.Add(tag);
|
||||
builtTagsByRawPath[tag.Name] = tag;
|
||||
builtDeclaredTags.Add(tag);
|
||||
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
{
|
||||
@@ -416,15 +422,18 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Member fan-out duplicate check: a member-path collision means two
|
||||
// configured structure tags produce the same member path, or a member
|
||||
// name collides with an independently-declared tag.
|
||||
if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
|
||||
if (builtTagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
|
||||
throw new InvalidOperationException(
|
||||
$"AbCip tag name collision: '{memberTag.Name}' is produced by both " +
|
||||
$"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " +
|
||||
$"'{existingMember.Name}'. Rename one of the configured tags to resolve.");
|
||||
_tagsByRawPath[memberTag.Name] = memberTag;
|
||||
builtTagsByRawPath[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Publish the finished tables by reference swap — readers see the old or the new, never a half-built one.
|
||||
_tagsByRawPath = builtTagsByRawPath;
|
||||
_declaredTags = builtDeclaredTags;
|
||||
_skippedTags.Commit();
|
||||
|
||||
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
|
||||
@@ -459,7 +468,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels, joins (bounded) and disposes every existing device state's probe loop + handles, then
|
||||
/// clears the device/route tables. Device part of <see cref="ShutdownAsync"/> only.
|
||||
/// </summary>
|
||||
private async Task ReleaseDeviceStatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The projection is replaced (not reused) when a config body is adopted; dispose the old one so its
|
||||
// poll loop does not outlive it and raise duplicate alarm events through this driver.
|
||||
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
var probeTask = state.ProbeTask;
|
||||
if (probeTask is null) continue;
|
||||
// Own bounded timeout, independent of the caller token: cancelling init must not abort the join
|
||||
// and dispose handles under a probe loop that is still running.
|
||||
try { await probeTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
|
||||
catch (TimeoutException) { }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.ProbeTask = null;
|
||||
state.DisposeHandles();
|
||||
}
|
||||
_devices.Clear();
|
||||
_devicesByRoutingKey.Clear();
|
||||
// Rebuilt wholesale by Initialize; stale entries would trip its member fan-out collision check.
|
||||
_tagsByRawPath = new(StringComparer.Ordinal); // swap, never Clear(): the retained poll engine / discovery may be enumerating
|
||||
_declaredTags = new();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -506,8 +551,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
_devices.Clear();
|
||||
_devicesByRoutingKey.Clear();
|
||||
_tagsByRawPath.Clear();
|
||||
_declaredTags.Clear();
|
||||
_tagsByRawPath = new(StringComparer.Ordinal);
|
||||
_declaredTags = new();
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
|
||||
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
|
||||
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
|
||||
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
|
||||
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
|
||||
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
|
||||
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
|
||||
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
|
||||
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
|
||||
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
|
||||
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
|
||||
/// <item><c>S:1</c> — status file, word 1.</item>
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// <item><c>I:017/07</c> — PLC-5 only: I/O word + bit indices are <b>octal</b> (#244); stored decimal.</item>
|
||||
/// <item><c>RTC:0.HR</c> — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245).</item>
|
||||
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247): parsed, not resolved at runtime.</item>
|
||||
/// </list>
|
||||
/// <para>The libplctag transport takes <see cref="ToLibplctagName"/> as its <c>name=...</c> attribute and the
|
||||
/// native transport re-parses the same string, so both are handed identical (decimal) numbers. This parser
|
||||
/// validates the shape + surfaces the structural pieces for driver-side routing (e.g. deciding whether a tag
|
||||
/// needs bit-level read-modify-write).</para>
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyAddress(
|
||||
string FileLetter,
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement,
|
||||
AbLegacyAddress? IndirectFileSource = null,
|
||||
AbLegacyAddress? IndirectWordSource = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the file number or the word number is sourced from another PCCC address evaluated
|
||||
/// at runtime (<c>N7:[N7:0]</c> or <c>N[N7:0]:5</c>). Neither transport can encode the bracket
|
||||
/// form, and resolving it needs a pre-read of the index word, which is not implemented — the
|
||||
/// driver answers an indirect tag <c>BadNotSupported</c>.
|
||||
/// </summary>
|
||||
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Converts the address to the libplctag library address format. Numeric indices are always
|
||||
/// emitted <b>decimal</b> (a PLC-5 octal I/O address is normalised at parse time). This is correct, not a
|
||||
/// double conversion: libplctag's <c>pccc.c</c> (<c>parse_pccc_elem_num</c> / <c>parse_pccc_bit_num</c>) parses
|
||||
/// decimal numerals only (a FIXME there notes octal is unsupported), so handing it the RSLogix octal text would
|
||||
/// read the wrong word. An indirect
|
||||
/// address re-emits its bracket form, which neither transport accepts — check
|
||||
/// <see cref="IsIndirect"/> first.
|
||||
/// </summary>
|
||||
/// <returns>The libplctag <c>name=...</c> attribute value for this address.</returns>
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
string file;
|
||||
if (IndirectFileSource is not null) file = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
|
||||
else file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var word = IndirectWordSource is not null ? $"[{IndirectWordSource.ToLibplctagName()}]" : WordNumber.ToString();
|
||||
var wordPart = $"{file}:{word}";
|
||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to parse a string into an AB legacy address (family-neutral: decimal indices, no function files).</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>The parsed address, or <see langword="null"/> when <paramref name="value"/> is not a valid PCCC address.</returns>
|
||||
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
|
||||
|
||||
/// <summary>
|
||||
/// Family-aware parser. PLC-5 reads <c>I:</c>/<c>O:</c> word and bit indices as octal
|
||||
/// (<c>I:017/07</c> = word 15, bit 7; a digit 8 or 9 is a parse error); MicroLogix additionally
|
||||
/// accepts the function-file letters. The record always stores decimal values.
|
||||
/// </summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <param name="family">The device's PLC family, or <see langword="null"/> for the family-neutral grammar.</param>
|
||||
/// <returns>The parsed address, or <see langword="null"/> when the string is not valid for the family.</returns>
|
||||
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||
return ParseCore(value.Trim(), profile, allowIndirect: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single authoring / deploy-gate / runtime address check. With a known <paramref name="family"/> the
|
||||
/// address must parse for that family; with an unknown one (<see langword="null"/>) it must parse for at
|
||||
/// least one family, so a caller that cannot see the device never rejects what the runtime would accept.
|
||||
/// </summary>
|
||||
/// <param name="address">The authored address text.</param>
|
||||
/// <param name="family">The device's PLC family, or <see langword="null"/> when it is not known.</param>
|
||||
/// <returns>An error message, or <see langword="null"/> when the address is acceptable.</returns>
|
||||
public static string? Validate(string? address, AbLegacyPlcFamily? family)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address)) return "Address is required.";
|
||||
if (family is { } f)
|
||||
{
|
||||
if (TryParse(address, f) is not null) return null;
|
||||
var hint = f == AbLegacyPlcFamily.Plc5 && TryParse(address, AbLegacyPlcFamily.Slc500) is not null
|
||||
? " (PLC-5 I:/O: word and bit indices are octal - 8 and 9 are invalid)"
|
||||
: IsFunctionLetterPrefix(address) ? " (function files are MicroLogix-only)" : "";
|
||||
return $"'{address}' is not a valid address for a {f} device{hint}.";
|
||||
}
|
||||
foreach (var candidate in Enum.GetValues<AbLegacyPlcFamily>())
|
||||
if (TryParse(address, candidate) is not null) return null;
|
||||
return $"'{address}' is not a valid PCCC file address for any PLC family.";
|
||||
}
|
||||
|
||||
private static bool IsFunctionLetterPrefix(string address)
|
||||
{
|
||||
var colon = address.IndexOf(':');
|
||||
return colon > 0 && IsFunctionFileLetter(address[..colon].Trim().ToUpperInvariant());
|
||||
}
|
||||
|
||||
private static AbLegacyAddress? ParseCore(string src, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
|
||||
{
|
||||
// BitIndex: trailing /N outside any bracketed inner address. The numeric parse is deferred until the
|
||||
// file letter is known (PLC-5 I/O bits are octal); the valid range depends on the parent word width.
|
||||
string? bitText = null;
|
||||
var slashIdx = src.LastIndexOf('/');
|
||||
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
|
||||
{
|
||||
bitText = src[(slashIdx + 1)..];
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
|
||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / HR ...) — top level only, since the inner
|
||||
// address of an indirect form may carry its own dot (N[T4:0.ACC]:5).
|
||||
string? subElement = null;
|
||||
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
var candidate = src[(dotIdx + 1)..];
|
||||
if (candidate.Length > 0 && candidate.All(char.IsLetter))
|
||||
{
|
||||
subElement = candidate.ToUpperInvariant();
|
||||
src = src[..dotIdx];
|
||||
}
|
||||
}
|
||||
|
||||
var colonIdx = IndexOfTopLevel(src, ':');
|
||||
if (colonIdx <= 0) return null;
|
||||
var filePart = src[..colonIdx];
|
||||
var wordPart = src[(colonIdx + 1)..];
|
||||
|
||||
// File letter + optional file number (decimal digits, or a bracketed indirect address).
|
||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||
var letterEnd = 1;
|
||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||
|
||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||
int? fileNumber = null;
|
||||
AbLegacyAddress? indirectFile = null;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
var fileTail = filePart[letterEnd..];
|
||||
if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
|
||||
{
|
||||
if (!allowIndirect) return null;
|
||||
indirectFile = ParseInner(fileTail[1..^1], profile);
|
||||
if (indirectFile is null) return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!int.TryParse(fileTail, out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject unknown file letters. Function-file letters are MicroLogix-only.
|
||||
var functionFile = IsFunctionFileLetter(letter);
|
||||
if (!IsKnownFileLetter(letter) && !(functionFile && profile?.SupportsFunctionFiles == true)) return null;
|
||||
|
||||
// I/O/S are single-letter system files and each function file is a single fixed instance: none carries
|
||||
// a file number. Accepting I3:0 would pass a malformed address straight to the transport; reject early.
|
||||
if ((fileNumber is not null || indirectFile is not null) && (IsNoFileNumberLetter(letter) || functionFile)) return null;
|
||||
|
||||
// Word part: a numeric literal (octal for PLC-5 I:/O:) or a bracketed indirect address.
|
||||
var octal = profile?.OctalIoAddressing == true && letter is "I" or "O";
|
||||
var word = 0;
|
||||
AbLegacyAddress? indirectWord = null;
|
||||
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
|
||||
{
|
||||
if (!allowIndirect) return null;
|
||||
indirectWord = ParseInner(wordPart[1..^1], profile);
|
||||
if (indirectWord is null) return null;
|
||||
}
|
||||
else if (!TryParseIndex(wordPart, octal, out word) || word < 0) return null;
|
||||
|
||||
int? bitIndex = null;
|
||||
if (bitText is not null)
|
||||
{
|
||||
if (!TryParseIndex(bitText, octal, out var bit) || bit < 0) return null;
|
||||
bitIndex = bit;
|
||||
|
||||
// Range-check against the file's word width: 16-bit N/B/I/O/S/A → 0..15, 32-bit L → 0..31.
|
||||
// F-files are floats and function files are structured — neither is bit-addressable.
|
||||
var maxBit = MaxBitIndexFor(letter);
|
||||
if (maxBit < 0 || bit > maxBit) return null;
|
||||
}
|
||||
|
||||
// A PCCC address cannot have both a sub-element and a bit index.
|
||||
if (subElement is not null && bitIndex is not null) return null;
|
||||
|
||||
// Sub-elements only exist on Timer / Counter / Control files and the function files — accepting
|
||||
// B3:0.DN or N7:0.FOO would produce an address the transport silently misinterprets.
|
||||
if (subElement is not null && !IsSubElementFileLetter(letter)) return null;
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
|
||||
}
|
||||
|
||||
/// <summary>Parses a bracketed inner address. Depth is capped at one: the inner address must be direct.</summary>
|
||||
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
|
||||
{
|
||||
var src = inner.Trim();
|
||||
if (src.Length == 0 || src.IndexOfAny(['[', ']']) >= 0) return null;
|
||||
return ParseCore(src, profile, allowIndirect: false);
|
||||
}
|
||||
|
||||
private static int IndexOfTopLevel(string s, char c)
|
||||
{
|
||||
var depth = 0;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == '[') depth++;
|
||||
else if (s[i] == ']') depth--;
|
||||
else if (depth == 0 && s[i] == c) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int LastIndexOfTopLevel(string s, char c)
|
||||
{
|
||||
var depth = 0;
|
||||
var last = -1;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == '[') depth++;
|
||||
else if (s[i] == ']') depth--;
|
||||
else if (depth == 0 && s[i] == c) last = i;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private static bool TryParseIndex(string text, bool octal, out int value)
|
||||
{
|
||||
if (!octal) return int.TryParse(text, out value);
|
||||
|
||||
// Octal accepts only the digits 0-7 (no sign); 8 and 9 are a parse error rather than a silent misread.
|
||||
value = 0;
|
||||
if (text.Length == 0) return false;
|
||||
var acc = 0;
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (c < '0' || c > '7') return false;
|
||||
acc = (acc * 8) + (c - '0');
|
||||
if (acc > ushort.MaxValue) return false;
|
||||
}
|
||||
value = acc;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highest valid bit index for a file letter, or <c>-1</c> if the file type is not
|
||||
/// bit-addressable. 16-bit element files (N/B/I/O/S/A) permit bits 0..15; the 32-bit
|
||||
/// L-file permits 0..31.
|
||||
/// </summary>
|
||||
private static int MaxBitIndexFor(string letter) => letter switch
|
||||
{
|
||||
"L" => 31,
|
||||
"N" or "B" or "I" or "O" or "S" or "A" => 15,
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||
{
|
||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element
|
||||
/// catalogue (<c>AbLegacyFunctionFile</c>).
|
||||
/// </summary>
|
||||
public static bool IsFunctionFileLetter(string letter) => letter switch
|
||||
{
|
||||
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> for file letters that carry no explicit file number in the
|
||||
/// PCCC spec. <c>I</c> (input), <c>O</c> (output), and <c>S</c> (status) are single-letter
|
||||
/// system files; a digit after the letter (e.g. <c>I3</c>) is a malformed address.
|
||||
/// </summary>
|
||||
private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S";
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> for file letters that may carry a sub-element suffix
|
||||
/// (<c>.ACC</c>, <c>.PRE</c>, etc.): Timer (<c>T</c>), Counter (<c>C</c>), Control (<c>R</c>) and the
|
||||
/// MicroLogix function files.
|
||||
/// </summary>
|
||||
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R" || IsFunctionFileLetter(letter);
|
||||
}
|
||||
+13
-4
@@ -15,7 +15,14 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
/// </summary>
|
||||
int MaxTagBytes,
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
bool SupportsLongFile,
|
||||
/// <summary>
|
||||
/// PLC-5 only: the word and bit indices on <c>I:</c>/<c>O:</c> references are octal
|
||||
/// (<c>I:017/07</c>), as RSLogix 5 displays them. SLC / MicroLogix / Logix-PCCC stay decimal.
|
||||
/// </summary>
|
||||
bool OctalIoAddressing = false,
|
||||
/// <summary>MicroLogix only: RTC / HSC / DLS / MMI / PTO / PWM / STI / EII / IOS / BHI function files.</summary>
|
||||
bool SupportsFunctionFiles = false)
|
||||
{
|
||||
/// <summary>Gets the profile for the specified PLC family.</summary>
|
||||
/// <param name="family">The PLC family.</param>
|
||||
@@ -41,21 +48,23 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
||||
SupportsLongFile: true); // L file available SLC 5/05+
|
||||
SupportsLongFile: true); // L file available SLC 5/05+ (decimal I:/O: indices, RSLogix 500)
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||
LibplctagPlcAttribute: "micrologix",
|
||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||
MaxTagBytes: 232,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
|
||||
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
|
||||
SupportsFunctionFiles: true); // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||
LibplctagPlcAttribute: "plc5",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // PLC-5 predates L files
|
||||
SupportsLongFile: false, // PLC-5 predates L files
|
||||
OctalIoAddressing: true); // RSLogix 5 displays I:/O: word + bit indices as octal (#244)
|
||||
|
||||
/// <summary>
|
||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||
|
||||
+27
-2
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -122,6 +123,12 @@ public static class AbLegacyTagDefinitionFactory
|
||||
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
|
||||
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
|
||||
/// </summary>
|
||||
private static bool IsIndirectFor(string address, AbLegacyPlcFamily? family)
|
||||
{
|
||||
if (family is { } f) return AbLegacyAddress.TryParse(address, f)?.IsIndirect == true;
|
||||
return Enum.GetValues<AbLegacyPlcFamily>().Any(c => AbLegacyAddress.TryParse(address, c)?.IsIndirect == true);
|
||||
}
|
||||
|
||||
private const string InspectionRawPath = "(inspect)";
|
||||
|
||||
/// <summary>
|
||||
@@ -145,7 +152,18 @@ public static class AbLegacyTagDefinitionFactory
|
||||
/// </remarks>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
public static IReadOnlyList<string> Inspect(string reference) => Inspect(reference, family: null);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Inspect(string)"/> plus the family-aware address check (<see cref="AbLegacyAddress.Validate"/>,
|
||||
/// the same authority the driver runtime uses). <paramref name="family"/> is the tag's device's PLC family, or
|
||||
/// <see langword="null"/> when unknown (then only an address no family accepts is flagged). An indirect
|
||||
/// address parses but the runtime always answers it <c>BadNotSupported</c>, so it is warned about too.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <param name="family">The owning device's PLC family, when known.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference, AbLegacyPlcFamily? family)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
||||
@@ -157,7 +175,14 @@ public static class AbLegacyTagDefinitionFactory
|
||||
|
||||
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
|
||||
// mapper — the driver reads the stored blob, not a trimmed copy of it.
|
||||
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
|
||||
if (FromTagConfig(reference, InspectionRawPath, out var accepted))
|
||||
{
|
||||
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
|
||||
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
|
||||
else if (IsIndirectFor(accepted.Address, family))
|
||||
warnings.Add($"AbLegacy address '{accepted.Address}' is indirect; indirect addressing is not supported and the tag reads BadNotSupported.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
|
||||
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
|
||||
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
|
||||
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
|
||||
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
|
||||
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
|
||||
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
|
||||
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
|
||||
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
|
||||
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
|
||||
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
|
||||
/// <item><c>S:1</c> — status file, word 1.</item>
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// </list>
|
||||
/// <para>Pass the original string straight through to libplctag's <c>name=...</c> attribute —
|
||||
/// the PLC-side decoder handles the format. This parser only validates the shape + surfaces
|
||||
/// the structural pieces for driver-side routing (e.g. deciding whether a tag needs
|
||||
/// bit-level read-modify-write).</para>
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyAddress(
|
||||
string FileLetter,
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
{
|
||||
/// <summary>Converts the address to the libplctag library address format.</summary>
|
||||
/// <returns>The libplctag <c>name=...</c> attribute value for this address.</returns>
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var wordPart = $"{file}:{WordNumber}";
|
||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to parse a string into an AB legacy address.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>The parsed address, or <see langword="null"/> when <paramref name="value"/> is not a valid PCCC address.</returns>
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// BitIndex: trailing /N. The valid range depends on the parent word width, which is
|
||||
// determined by the file letter (16-bit N/B/I/O/S/A → 0..15, 32-bit L → 0..31). Capture
|
||||
// the raw value here and range-check it once the file letter is known (see below).
|
||||
int? bitIndex = null;
|
||||
var slashIdx = src.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0) return null;
|
||||
bitIndex = bit;
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
|
||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||
string? subElement = null;
|
||||
var dotIdx = src.LastIndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
var candidate = src[(dotIdx + 1)..];
|
||||
if (candidate.Length > 0 && candidate.All(char.IsLetter))
|
||||
{
|
||||
subElement = candidate.ToUpperInvariant();
|
||||
src = src[..dotIdx];
|
||||
}
|
||||
}
|
||||
|
||||
var colonIdx = src.IndexOf(':');
|
||||
if (colonIdx <= 0) return null;
|
||||
var filePart = src[..colonIdx];
|
||||
var wordPart = src[(colonIdx + 1)..];
|
||||
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
|
||||
|
||||
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||
var letterEnd = 1;
|
||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||
|
||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||
int? fileNumber = null;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
}
|
||||
|
||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||
if (!IsKnownFileLetter(letter)) return null;
|
||||
|
||||
// Range-check the bit index against the file's word width. A PCCC N/B/I/O/S/A word is a
|
||||
// 16-bit element, so valid bit indices are 0..15; an L-file element is 32-bit (0..31).
|
||||
// F-files are 32-bit IEEE-754 floats and are not bit-addressable at all.
|
||||
if (bitIndex is int b)
|
||||
{
|
||||
var maxBit = MaxBitIndexFor(letter);
|
||||
if (maxBit < 0 || b > maxBit) return null;
|
||||
}
|
||||
|
||||
// I/O/S are single-letter system files — they carry no file number in the PCCC spec.
|
||||
// Accepting I3:0 or S2:1 would pass a malformed address straight to libplctag; reject early.
|
||||
if (fileNumber is not null && IsNoFileNumberLetter(letter)) return null;
|
||||
|
||||
// A PCCC address cannot have both a sub-element and a bit index: the word is either
|
||||
// structured (T4:0.ACC) or bit-addressed (N7:0/3), never both.
|
||||
if (subElement is not null && bitIndex is not null) return null;
|
||||
|
||||
// Sub-elements are only meaningful on Timer (T), Counter (C), and Control (R) files —
|
||||
// those are the only structured-element file types in the PCCC spec. Accepting B3:0.DN
|
||||
// or N7:0.FOO would produce an address libplctag silently misinterprets.
|
||||
if (subElement is not null && !IsSubElementFileLetter(letter)) return null;
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highest valid bit index for a file letter, or <c>-1</c> if the file type is not
|
||||
/// bit-addressable. 16-bit element files (N/B/I/O/S/A) permit bits 0..15; the 32-bit
|
||||
/// L-file permits 0..31.
|
||||
/// </summary>
|
||||
private static int MaxBitIndexFor(string letter) => letter switch
|
||||
{
|
||||
"L" => 31,
|
||||
"N" or "B" or "I" or "O" or "S" or "A" => 15,
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||
{
|
||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> for file letters that carry no explicit file number in the
|
||||
/// PCCC spec. <c>I</c> (input), <c>O</c> (output), and <c>S</c> (status) are single-letter
|
||||
/// system files; a digit after the letter (e.g. <c>I3</c>) is a malformed address.
|
||||
/// </summary>
|
||||
private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S";
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> for file letters that may carry a sub-element suffix
|
||||
/// (<c>.ACC</c>, <c>.PRE</c>, etc.). Only Timer (<c>T</c>), Counter (<c>C</c>), and
|
||||
/// Control (<c>R</c>) files have structured elements in the PCCC spec.
|
||||
/// </summary>
|
||||
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R";
|
||||
}
|
||||
@@ -143,8 +143,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it
|
||||
// without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the
|
||||
// overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
|
||||
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
_logger.LogInformation(
|
||||
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
|
||||
@@ -255,7 +260,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels, bounded-joins and disposes every existing device state, then clears the table.
|
||||
/// Device-state part of <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
|
||||
/// </summary>
|
||||
private async Task ReleaseDeviceStatesAsync()
|
||||
{
|
||||
if (_devices.Count == 0) return;
|
||||
foreach (var state in _devices.Values)
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToList();
|
||||
if (loops.Count > 0)
|
||||
{
|
||||
var all = Task.WhenAll(loops);
|
||||
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
|
||||
if (finished != all)
|
||||
_logger.LogWarning(
|
||||
"AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}",
|
||||
loops.Count, LoopJoinTimeout, _driverInstanceId);
|
||||
}
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.ProbeTask = null;
|
||||
state.DisposeRuntimes();
|
||||
}
|
||||
_devices.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -355,6 +388,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
// Indirect addresses (N7:[N7:0]) parse but are not resolved at runtime (#247) — answer the
|
||||
// refusal before a runtime is created for a name neither transport can encode.
|
||||
var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
if (preParsed is null)
|
||||
{
|
||||
WarnMalformedOnce(device, def);
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (preParsed.IsIndirect)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int status;
|
||||
@@ -368,7 +416,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
if (status != 0)
|
||||
value = null;
|
||||
else if (IsArrayTag(def))
|
||||
@@ -455,7 +503,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
|
||||
// A malformed address is a config error, not a comms fault (BadNodeIdUnknown, warned once per tag).
|
||||
if (parsed is null)
|
||||
{
|
||||
WarnMalformedOnce(device, def);
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created.
|
||||
if (parsed.IsIndirect)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
|
||||
// or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init
|
||||
@@ -610,7 +673,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.EffectiveCipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: _options.Probe.ProbeAddress!,
|
||||
// Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through
|
||||
// so the transport reports the failure as it always did.
|
||||
TagName: AbLegacyAddress.TryParse(_options.Probe.ProbeAddress, state.Options.PlcFamily)?.ToLibplctagName()
|
||||
?? _options.Probe.ProbeAddress!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
|
||||
IAbLegacyTagRuntime? probeRuntime = null;
|
||||
@@ -858,6 +924,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
keep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tag whose address does not parse for its device's PLC family (e.g. a PLC-5 octal I/O digit 8/9, a
|
||||
/// function-file letter off MicroLogix) is a configuration error: answered <c>BadNodeIdUnknown</c> with the
|
||||
/// handle/health untouched, and warned once per tag so a polled tag does not flood the log.
|
||||
/// </summary>
|
||||
private void WarnMalformedOnce(DeviceState device, AbLegacyTagDefinition def)
|
||||
{
|
||||
if (!device.MalformedAddressLogged.TryAdd(def.Name, 0)) return;
|
||||
_logger.LogWarning(
|
||||
"AbLegacy tag address is not valid for the device's PLC family. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Family={Family}",
|
||||
_driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily);
|
||||
}
|
||||
|
||||
/// <summary>The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so
|
||||
/// the refusal is asked about exactly the runtime a write would otherwise create.</summary>
|
||||
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
|
||||
@@ -883,9 +962,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}.");
|
||||
|
||||
var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed));
|
||||
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
|
||||
@@ -1090,6 +1169,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// </summary>
|
||||
public bool FirstNonZeroStatusLogged { get; set; }
|
||||
|
||||
/// <summary>Tags already warned about for a malformed address (once-per-tag log).</summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, byte> MalformedAddressLogged { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Disposes all cached tag runtimes.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file sub-element catalogue (#245): the driver-surface type of
|
||||
/// <c>RTC:0.HR</c>, <c>HSC:0.ACC</c>, ... Covers the commonly-addressed members per file — not exhaustive
|
||||
/// (Rockwell defines 30+ on RTC alone). <b>Unverified on the wire:</b> the layouts are transcribed from the
|
||||
/// MicroLogix instruction-set reference; neither transport has been captured against these files and the
|
||||
/// native transport has no encoding for them at all.
|
||||
/// </summary>
|
||||
public static class AbLegacyFunctionFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>; an unrecognised
|
||||
/// sub-element falls back to <see cref="DriverDataType.Int32"/> so the driver never refuses a member the
|
||||
/// catalogue happens to omit.
|
||||
/// </summary>
|
||||
/// <param name="fileLetter">The function-file letter (RTC, HSC, ...).</param>
|
||||
/// <param name="subElement">The sub-element mnemonic, or null.</param>
|
||||
/// <returns>The mapped driver data type.</returns>
|
||||
public static DriverDataType SubElementType(string fileLetter, string? subElement)
|
||||
{
|
||||
if (subElement is null) return DriverDataType.Int32;
|
||||
return (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant()) switch
|
||||
{
|
||||
// Real-time clock: time fields are 16-bit words; DS (disabled) / BL (battery low) are flags.
|
||||
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
|
||||
|
||||
// High-speed counter: accumulator/preset/overflow/underflow are numeric, the rest status bits.
|
||||
("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or ("HSC", "IN") or ("HSC", "IH") or
|
||||
("HSC", "IL") or ("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean,
|
||||
|
||||
// Daylight saving + memory-module info.
|
||||
("DLS", "EN") => DriverDataType.Boolean,
|
||||
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
|
||||
|
||||
// Pulse-train / PWM outputs.
|
||||
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or ("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
|
||||
("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or ("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean,
|
||||
|
||||
// Selectable timed interrupt + event input interrupt.
|
||||
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or ("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
|
||||
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or ("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
|
||||
|
||||
// Everything else (HR/MIN/SEC/MON/DAY/YR/DOW, ACC/PRE/OVF/UNF, STR/STD, FT/LBN, OF/IDA/ODA/PE/PD,
|
||||
// SPM/ER/PFN, ID/TYP, OS/FRN/BSN/CC, ...) is a numeric word.
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -154,10 +154,23 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFunctionFileName(string tagName)
|
||||
{
|
||||
var colon = tagName.IndexOf(':');
|
||||
return colon > 0 && AbLegacyAddress.IsFunctionFileLetter(tagName[..colon].ToUpperInvariant());
|
||||
}
|
||||
|
||||
private static PcccAddress ToPcccAddress(string tagName)
|
||||
{
|
||||
// Family-neutral parse is correct here: the driver hands the runtime AbLegacyAddress.ToLibplctagName(), which is
|
||||
// already decimal-normalised (PLC-5 octal I/O included), so no family is needed to read it back.
|
||||
var a = AbLegacyAddress.TryParse(tagName)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is not a valid PCCC address.");
|
||||
?? throw new AbTransportException(AbStatus.Unsupported,
|
||||
IsFunctionFileName(tagName)
|
||||
? $"'{tagName}' is a MicroLogix function file; the native transport has no encoding for it (unverified on the wire)."
|
||||
: $"'{tagName}' is not a valid PCCC address.");
|
||||
if (a.IsIndirect)
|
||||
throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is an indirect address; indirect addressing is not supported.");
|
||||
return a.SubElement is not null
|
||||
? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
|
||||
: new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex);
|
||||
|
||||
@@ -84,7 +84,7 @@ public static class FocasCapabilityMatrix
|
||||
/// <returns>A set of accepted PMC letter strings.</returns>
|
||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
|
||||
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
|
||||
@@ -178,8 +178,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's
|
||||
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
|
||||
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
{
|
||||
@@ -316,7 +320,56 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message));
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels, bounded-joins and disposes every existing device state (probe / recycle / fixed-tree
|
||||
/// loops + client) and the alarm projection, then clears the table. Device part of
|
||||
/// <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
|
||||
/// </summary>
|
||||
private async Task ReleaseDeviceStatesAsync()
|
||||
{
|
||||
if (_alarmProjection is { } proj)
|
||||
{
|
||||
await proj.DisposeAsync().ConfigureAwait(false);
|
||||
_alarmProjection = null;
|
||||
}
|
||||
if (_devices.Count == 0) return;
|
||||
var loops = new List<Task>();
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
|
||||
try { state.RecycleCts?.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
|
||||
try { state.FixedTreeCts?.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
|
||||
if (state.ProbeTask is { } pt) loops.Add(pt);
|
||||
if (state.RecycleTask is { } rt) loops.Add(rt);
|
||||
if (state.FixedTreeTask is { } ft) loops.Add(ft);
|
||||
}
|
||||
if (loops.Count > 0)
|
||||
{
|
||||
var all = Task.WhenAll(loops);
|
||||
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
|
||||
if (finished != all)
|
||||
_logger.LogWarning("FOCAS re-initialise: {Count} background loop(s) did not stop within {Timeout}; proceeding",
|
||||
loops.Count, LoopJoinTimeout);
|
||||
}
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.ProbeTask = null;
|
||||
state.RecycleCts?.Dispose();
|
||||
state.RecycleCts = null;
|
||||
state.RecycleTask = null;
|
||||
state.FixedTreeCts?.Dispose();
|
||||
state.FixedTreeCts = null;
|
||||
state.FixedTreeTask = null;
|
||||
state.DisposeClient();
|
||||
}
|
||||
_devices.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -158,11 +158,22 @@ public sealed record MTConnectStreamsResult(
|
||||
/// <c>false</c>, the shape of every ordinary scalar observation.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="SampleCount">
|
||||
/// The observation's own <c>sampleCount</c> attribute — the number of values a <c>TIME_SERIES</c>
|
||||
/// vector claims to carry (#627). The index checks the parsed vector against it. <c>null</c> when absent.
|
||||
/// </param>
|
||||
/// <param name="SampleRate">
|
||||
/// The observation's own <c>sampleRate</c> attribute (samples per second) on a <c>TIME_SERIES</c>
|
||||
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
|
||||
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
|
||||
/// </param>
|
||||
public sealed record MTConnectObservation(
|
||||
string DataItemId,
|
||||
string Value,
|
||||
DateTime TimestampUtc,
|
||||
bool IsStructured = false);
|
||||
bool IsStructured = false,
|
||||
int? SampleCount = null,
|
||||
double? SampleRate = null);
|
||||
|
||||
/// <summary>
|
||||
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
||||
|
||||
@@ -49,8 +49,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
|
||||
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
|
||||
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
|
||||
/// value rather than approximated: a non-Float64 array tag and a <c>DATA_SET</c> / <c>TABLE</c>
|
||||
/// observation (a Float64 <c>TIME_SERIES</c> vector is served as <c>double[]</c>, #627). The latter carries its
|
||||
/// real content in <c><Entry key=…></c> child elements, which reach a text-valued
|
||||
/// observation concatenated with the keys discarded — a two-entry set reads as the single
|
||||
/// token <c>"12"</c> — and because the type inference correctly demotes those
|
||||
@@ -85,9 +85,12 @@ internal sealed class MTConnectObservationIndex
|
||||
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
|
||||
/// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
|
||||
/// <summary>The observation's shape is real data this build cannot represent (a DATA_SET/TABLE, or a non-Float64 array tag).</summary>
|
||||
private const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>A TIME_SERIES vector whose element count disagrees with its declared <c>sampleCount</c>.</summary>
|
||||
private const uint BadDecodingError = 0x80070000u;
|
||||
|
||||
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
@@ -268,13 +271,11 @@ internal sealed class MTConnectObservationIndex
|
||||
|
||||
_tags.TryGetValue(observation.DataItemId, out var tag);
|
||||
|
||||
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
|
||||
// arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
|
||||
// type mismatch, and emphatically not the first element parsed out and served as Good.
|
||||
// Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
|
||||
// TIME_SERIES vectors arrive as one whitespace-separated string (#627). Checked AFTER the
|
||||
// sentinel so an unavailable array tag still reports the truthful no-comms.
|
||||
if (tag is { IsArray: true })
|
||||
{
|
||||
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
|
||||
return BuildArraySnapshot(observation, tag, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
// An unauthored observation has no declared target type; String is the only coercion that
|
||||
@@ -286,6 +287,40 @@ internal sealed class MTConnectObservationIndex
|
||||
: new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a <c>TIME_SERIES</c> vector into a <c>double[]</c>. Only <see cref="DriverDataType.Float64"/>
|
||||
/// arrays are served (the MTConnect TIME_SERIES element type is a floating-point sample); any other
|
||||
/// authored array type is <c>BadNotSupported</c> rather than an approximation. All-or-nothing: one
|
||||
/// unparseable or non-finite token, or a count that disagrees with the observation's own
|
||||
/// <c>sampleCount</c> (or the tag's fixed <c>ArrayDim</c>), makes the whole vector Bad — a partial
|
||||
/// vector served as Good would be a silently wrong waveform.
|
||||
/// </summary>
|
||||
private static DataValueSnapshot BuildArraySnapshot(
|
||||
MTConnectObservation observation, MTConnectTagDefinition tag, DateTime sourceTimestamp, DateTime serverTimestamp)
|
||||
{
|
||||
DataValueSnapshot Bad(uint status) => new(null, status, sourceTimestamp, serverTimestamp);
|
||||
|
||||
if (tag.DriverDataType != DriverDataType.Float64) return Bad(BadNotSupported);
|
||||
|
||||
var tokens = observation.Value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
var values = new double[tokens.Length];
|
||||
for (var i = 0; i < tokens.Length; i++)
|
||||
{
|
||||
if (string.Equals(tokens[i], UnavailableSentinel, StringComparison.Ordinal)) return Bad(BadNoCommunication);
|
||||
|
||||
if (!double.TryParse(tokens[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]) ||
|
||||
!double.IsFinite(values[i]))
|
||||
{
|
||||
return Bad(BadTypeMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
if (observation.SampleCount is { } declared && declared != values.Length) return Bad(BadDecodingError);
|
||||
if (tag.ArrayDim > 0 && tag.ArrayDim != values.Length) return Bad(BadDecodingError);
|
||||
|
||||
return new DataValueSnapshot(values, Good, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the Agent's text to the tag's authored type. Every parse is
|
||||
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
|
||||
|
||||
@@ -377,6 +377,8 @@ internal static class MTConnectStreamsParser
|
||||
string? dataItemId = null;
|
||||
var timestampUtc = default(DateTime);
|
||||
string? conditionState = null;
|
||||
int? sampleCount = null;
|
||||
double? sampleRate = null;
|
||||
|
||||
if (isObservation)
|
||||
{
|
||||
@@ -385,6 +387,24 @@ internal static class MTConnectStreamsParser
|
||||
timestampUtc = ReadTimestampUtc(reader, dataItemId);
|
||||
conditionState = isCondition ? ConditionState(name) : null;
|
||||
|
||||
// TIME_SERIES metadata rides the observation element itself. Read here, before the subtree
|
||||
// walk moves the reader off this element's attributes. Lenient: a vendor's unparseable
|
||||
// attribute is dropped rather than failing the whole document (the vector is checked
|
||||
// against the declared sampleCount only when one parses).
|
||||
if (!isCondition)
|
||||
{
|
||||
sampleCount = int.TryParse(
|
||||
MTConnectXml.OptionalAttribute(reader, "sampleCount"),
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var count) && count >= 0 ? count : null;
|
||||
sampleRate = double.TryParse(
|
||||
MTConnectXml.OptionalAttribute(reader, "sampleRate"),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var rate) && double.IsFinite(rate) ? rate : null;
|
||||
}
|
||||
|
||||
// The slot is reserved BEFORE the subtree is read, and filled after: an observation is
|
||||
// only complete once its text and its child-element flag are known, but appending it
|
||||
// then would report a nested observation ahead of the one enclosing it. The tree walk
|
||||
@@ -430,7 +450,9 @@ internal static class MTConnectStreamsParser
|
||||
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
|
||||
// the element NAME, so child content cannot corrupt it, and flagging one would throw
|
||||
// away a perfectly well-determined state.
|
||||
IsStructured: !isCondition && hasElements);
|
||||
IsStructured: !isCondition && hasElements,
|
||||
SampleCount: sampleCount,
|
||||
SampleRate: sampleRate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -211,6 +211,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
|
||||
// it with no teardown first); `Session = session` below would otherwise orphan the previous session,
|
||||
// its keep-alive handler and any reconnect handler.
|
||||
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
@@ -775,32 +779,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Cancels any in-flight reconnect, detaches the keep-alive handler and closes + disposes the current
|
||||
/// session. The session part of <see cref="ShutdownAsync"/>; also run at the top of
|
||||
/// <see cref="InitializeAsync"/> so a re-initialise on a live driver (#630) does not orphan the
|
||||
/// previous session and its reconnect handler. Subscriptions are NOT touched here.
|
||||
/// </summary>
|
||||
private async Task ReleaseSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
|
||||
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
|
||||
// whether or not the wire-side delete succeeds since the local handles are useless
|
||||
// after close anyway. Before deleting each subscription we detach the Notification
|
||||
// handlers we attached at subscribe time so the SDK's invocation list no longer
|
||||
// holds the driver instance through the closure.
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
{
|
||||
DetachNotificationHandlers(rs.ItemHandlers);
|
||||
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
foreach (var ras in _alarmSubscriptions.Values)
|
||||
{
|
||||
try { ras.EventItem.Notification -= ras.Handler; }
|
||||
catch { /* best-effort */ }
|
||||
try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_alarmSubscriptions.Clear();
|
||||
|
||||
// Abort any in-flight reconnect attempts before touching the session — BeginReconnect's
|
||||
// retry loop holds a reference to the current session and would fight Session.CloseAsync
|
||||
// if left spinning. Take the handler under _probeLock so a keep-alive callback racing
|
||||
@@ -835,6 +821,35 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
try { if (sessionToClose is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
try { sessionToClose?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
|
||||
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
|
||||
// whether or not the wire-side delete succeeds since the local handles are useless
|
||||
// after close anyway. Before deleting each subscription we detach the Notification
|
||||
// handlers we attached at subscribe time so the SDK's invocation list no longer
|
||||
// holds the driver instance through the closure.
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
{
|
||||
DetachNotificationHandlers(rs.ItemHandlers);
|
||||
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
foreach (var ras in _alarmSubscriptions.Values)
|
||||
{
|
||||
try { ras.EventItem.Notification -= ras.Handler; }
|
||||
catch { /* best-effort */ }
|
||||
try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_alarmSubscriptions.Clear();
|
||||
|
||||
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
_namespaceMap = null;
|
||||
_connectedEndpointUrl = null;
|
||||
|
||||
|
||||
@@ -16,3 +16,20 @@ public enum S7CpuType
|
||||
S71200 = 30,
|
||||
S71500 = 40,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ISO-TSAP connection resource requested during the handshake — the FIRST byte of the remote TSAP
|
||||
/// (the second byte is <c>rack × 0x20 + slot</c>). Values are the on-wire bytes. Leave unset to keep
|
||||
/// S7.Net's per-CPU default (<see cref="S7Basic"/> for the S7-300/400/1200/1500 families).
|
||||
/// </summary>
|
||||
public enum S7ConnectionType
|
||||
{
|
||||
/// <summary>Programming-device (PG) connection resource.</summary>
|
||||
PG = 1,
|
||||
|
||||
/// <summary>Operator-panel (OP/HMI) connection resource.</summary>
|
||||
OP = 2,
|
||||
|
||||
/// <summary>"S7 basic" / other-communication connection resource — S7.Net's default.</summary>
|
||||
S7Basic = 3,
|
||||
}
|
||||
|
||||
@@ -51,6 +51,34 @@ public sealed class S7DriverOptions
|
||||
/// </summary>
|
||||
public short Slot { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Optional override of the LOCAL ISO-TSAP, as two hex bytes <c>"01.00"</c> (also accepted:
|
||||
/// <c>"0100"</c>, <c>"0x0100"</c>). Null/blank keeps S7.Net's per-CPU default (<c>01.00</c>).
|
||||
/// Needed for CPUs / gateways that reserve connection resources by TSAP (S7-400H, CP343, LOGO!).
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
|
||||
public string? LocalTsap { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override of the REMOTE ISO-TSAP, same text form as <see cref="LocalTsap"/>
|
||||
/// (e.g. <c>"03.01"</c> = S7-basic resource, rack 0 slot 1). Null/blank keeps the slot-derived default.
|
||||
/// Mutually exclusive with <see cref="ConnectionType"/>.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
|
||||
public string? RemoteTsap { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional connection resource (PG / OP / S7Basic) written into the first byte of the slot-derived
|
||||
/// remote TSAP, keeping <c>rack × 0x20 + slot</c> as the second byte. Null keeps the default.
|
||||
/// Mutually exclusive with <see cref="RemoteTsap"/>.
|
||||
/// </summary>
|
||||
public S7ConnectionType? ConnectionType { get; init; }
|
||||
|
||||
/// <summary>The authoring/load error for setting both <see cref="RemoteTsap"/> and <see cref="ConnectionType"/>; shared by the factory and the AdminUI form so they never disagree.</summary>
|
||||
public const string TsapConflictDetail =
|
||||
"sets both RemoteTsap and ConnectionType; ConnectionType only fills the first byte of the " +
|
||||
"slot-derived remote TSAP, so choose one.";
|
||||
|
||||
/// <summary>Connect + per-operation timeout.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
@@ -139,6 +167,31 @@ public sealed class S7DriverOptions
|
||||
public const int DefaultSpanReadMaxSpanBytes = 220;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a TSAP as either hex text (<c>"03.01"</c>) or a JSON number (<c>769</c>, the form the driver
|
||||
/// factory also accepts) and always writes the hex text. Without it a numeric TSAP would fail to bind to
|
||||
/// the string property, and a typed consumer (the AdminUI form, the Test Connect probe) would fall back
|
||||
/// to defaults — a form Save would then silently erase the authored config.
|
||||
/// </summary>
|
||||
public sealed class S7TsapJsonConverter : System.Text.Json.Serialization.JsonConverter<string?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == System.Text.Json.JsonTokenType.Null) return null;
|
||||
if (reader.TokenType == System.Text.Json.JsonTokenType.Number && reader.TryGetUInt16(out var n))
|
||||
return $"{n >> 8:X2}.{n & 0xFF:X2}";
|
||||
if (reader.TokenType == System.Text.Json.JsonTokenType.String) return reader.GetString();
|
||||
throw new System.Text.Json.JsonException("S7 TSAP must be hex text such as \"03.01\" or a 16-bit number.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(System.Text.Json.Utf8JsonWriter writer, string? value, System.Text.Json.JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null) writer.WriteNullValue(); else writer.WriteStringValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class S7ProbeOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
|
||||
@@ -197,5 +250,22 @@ public enum S7DataType
|
||||
Float32,
|
||||
Float64,
|
||||
String,
|
||||
/// <summary>S7 DATE_AND_TIME (DT, 8-byte BCD) → DateTime. The other date/time members below were appended after it.</summary>
|
||||
DateTime,
|
||||
/// <summary>S7 WSTRING (UTF-16BE, 4-byte header) → String. <c>StringLength</c> is the declared max in characters (≤ 16382).</summary>
|
||||
WString,
|
||||
/// <summary>S7 CHAR (1 byte, Latin-1) → one-character String.</summary>
|
||||
Char,
|
||||
/// <summary>S7 WCHAR (UTF-16BE code unit) → one-character String.</summary>
|
||||
WChar,
|
||||
/// <summary>S7 DTL (12 bytes, ns resolution) → DateTime.</summary>
|
||||
Dtl,
|
||||
/// <summary>S7 DATE (UInt16 days since 1990-01-01) → DateTime.</summary>
|
||||
Date,
|
||||
/// <summary>S7 TIME_OF_DAY (UInt32 ms since midnight) → Int32 milliseconds.</summary>
|
||||
Tod,
|
||||
/// <summary>S7 TIME (Int32 ms, signed duration) → Int32 milliseconds.</summary>
|
||||
Time,
|
||||
/// <summary>S7 S5TIME (BCD + time base) → Int32 milliseconds.</summary>
|
||||
S5Time,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,20 @@ public static class S7TagDefinitionFactory
|
||||
/// <summary>S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max).</summary>
|
||||
private const int MaxStringLength = 254;
|
||||
|
||||
/// <summary>S7 WSTRING max length in characters (S7-1500; the 4-byte header's UInt16 is capped at 16382 so the field fits a DB).</summary>
|
||||
private const int MaxWStringLength = 16382;
|
||||
|
||||
/// <summary>
|
||||
/// The declared-length ceiling for a type that carries a <c>stringLength</c>, or null for a type that
|
||||
/// does not (every non-string type ignores the key, so a copy-paste remnant never blocks parsing).
|
||||
/// </summary>
|
||||
private static int? MaxLengthFor(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.String => MaxStringLength,
|
||||
S7DataType.WString => MaxWStringLength,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
|
||||
@@ -55,7 +69,7 @@ public static class S7TagDefinitionFactory
|
||||
// Range-guard applies only to String tags: an S7 string can't exceed 254 chars, and a
|
||||
// negative length is meaningless. For non-String types stringLength is irrelevant and any
|
||||
// authored value (including an out-of-range remnant from copy-paste) must not block parsing.
|
||||
if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false;
|
||||
if (MaxLengthFor(dataType) is { } maxLen && (stringLength < 0 || stringLength > maxLen)) return false;
|
||||
// Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray)
|
||||
// honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror
|
||||
// that here so the driver's def agrees byte-for-byte with the materialised OPC UA node's
|
||||
@@ -230,14 +244,14 @@ public static class S7TagDefinitionFactory
|
||||
if (TagConfigJson.DescribeInvalidEnum<S7DataType>(root, DataTypeKey) is { } w) warnings.Add(w);
|
||||
|
||||
if (TagConfigJson.TryReadEnumStrict(root, DataTypeKey, S7DataType.Int16, out var dataType)
|
||||
&& dataType == S7DataType.String)
|
||||
&& MaxLengthFor(dataType) is { } maxLen)
|
||||
{
|
||||
var stringLength = ReadInt(root, "stringLength");
|
||||
if (stringLength < 0 || stringLength > MaxStringLength)
|
||||
if (stringLength < 0 || stringLength > maxLen)
|
||||
{
|
||||
warnings.Add(
|
||||
$"S7 String tag has a 'stringLength' of {stringLength} — an S7 string is 0–"
|
||||
+ $"{MaxStringLength} characters, so the tag is rejected (BadNodeIdUnknown).");
|
||||
$"S7 {dataType} tag has a 'stringLength' of {stringLength} — an S7 {dataType} is 0–"
|
||||
+ $"{maxLen} characters, so the tag is rejected (BadNodeIdUnknown).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ public interface IS7Plc : IDisposable
|
||||
/// <summary>Closes the S7 connection. Best-effort and idempotent.</summary>
|
||||
void Close();
|
||||
|
||||
/// <summary>
|
||||
/// PDU size negotiated with the CPU during <see cref="OpenAsync"/>, in bytes (typically 240 on S7-300/400
|
||||
/// and S7-1200/1500 defaults, 480/960 with extended PDU); 0 when unknown / not yet connected. Default
|
||||
/// interface member so a test fake that predates it keeps compiling.
|
||||
/// </summary>
|
||||
int MaxPduSize => 0;
|
||||
|
||||
/// <summary>Reads a scalar value by S7 address string (e.g. <c>DB1.DBW0</c>); the boxed type follows the size suffix.</summary>
|
||||
/// <param name="address">The S7 address string.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read.</param>
|
||||
@@ -94,6 +101,16 @@ public interface IS7PlcFactory
|
||||
/// <param name="timeout">Connect + per-operation timeout, applied to the underlying read/write timeouts.</param>
|
||||
/// <returns>A new, not-yet-opened <see cref="IS7Plc"/>.</returns>
|
||||
IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a connection from the full driver options, so an optional TSAP / connection-type override
|
||||
/// reaches the handshake. The default forwards to the six-argument overload (no override); the
|
||||
/// real factory honours <see cref="S7DriverOptions.LocalTsap"/> and friends.
|
||||
/// </summary>
|
||||
/// <param name="options">The driver options.</param>
|
||||
/// <returns>A new, not-yet-opened <see cref="IS7Plc"/>.</returns>
|
||||
IS7Plc Create(S7DriverOptions options) =>
|
||||
Create(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -114,8 +131,23 @@ internal sealed class S7PlcAdapter : IS7Plc
|
||||
/// <param name="slot">CPU slot.</param>
|
||||
/// <param name="timeout">Connect + per-operation timeout.</param>
|
||||
public S7PlcAdapter(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
||||
: this(new Plc(S7CpuTypeMap.ToS7Net(cpuType), host, port, rack, slot), timeout)
|
||||
{
|
||||
_plc = new Plc(S7CpuTypeMap.ToS7Net(cpuType), host, port, rack, slot);
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance with an explicit TSAP pair (<c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c> override).</summary>
|
||||
/// <param name="host">PLC IP address or hostname.</param>
|
||||
/// <param name="port">TCP port.</param>
|
||||
/// <param name="tsap">The effective TSAP pair.</param>
|
||||
/// <param name="timeout">Connect + per-operation timeout.</param>
|
||||
public S7PlcAdapter(string host, int port, global::S7.Net.Protocol.TsapPair tsap, TimeSpan timeout)
|
||||
: this(new Plc(host, port, tsap), timeout)
|
||||
{
|
||||
}
|
||||
|
||||
private S7PlcAdapter(Plc plc, TimeSpan timeout)
|
||||
{
|
||||
_plc = plc;
|
||||
_operationTimeout = timeout;
|
||||
// S7netplus writes timeouts into the underlying TcpClient via ReadTimeout / WriteTimeout
|
||||
// (milliseconds). Set before OpenAsync so the handshake itself honours the bound — matches
|
||||
@@ -132,6 +164,9 @@ internal sealed class S7PlcAdapter : IS7Plc
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => _plc.IsConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MaxPduSize => _plc.MaxPDUSize;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task OpenAsync(CancellationToken cancellationToken) => _plc.OpenAsync(cancellationToken);
|
||||
|
||||
@@ -187,4 +222,10 @@ internal sealed class S7PlcFactory : IS7PlcFactory
|
||||
/// <inheritdoc />
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
|
||||
new S7PlcAdapter(cpuType, host, port, rack, slot, timeout);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IS7Plc Create(S7DriverOptions options) =>
|
||||
S7Tsap.Resolve(options) is { } tsap
|
||||
? new S7PlcAdapter(options.Host, options.Port, tsap, options.Timeout)
|
||||
: new S7PlcAdapter(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,8 @@ public sealed class S7Driver
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
/// <summary>OPC UA StatusCode used when a written value has the wrong shape / length / element type.</summary>
|
||||
private const uint StatusBadTypeMismatch = 0x80740000u;
|
||||
/// <summary>OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read).</summary>
|
||||
private const uint StatusBadOutOfRange = 0x803C0000u;
|
||||
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
|
||||
@@ -223,6 +225,11 @@ public sealed class S7Driver
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
||||
// runs it with no teardown first). Stop the previous probe loop and drop the previous connection
|
||||
// before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the
|
||||
// subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseConnectionAsync().ConfigureAwait(false);
|
||||
Health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
@@ -269,8 +276,7 @@ public sealed class S7Driver
|
||||
|
||||
// Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
|
||||
// uses) so the read/write timeouts are baked in identically on connect and reconnect.
|
||||
var plc = _plcFactory.Create(
|
||||
_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
|
||||
var plc = _plcFactory.Create(_options);
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
@@ -298,6 +304,7 @@ public sealed class S7Driver
|
||||
Plc = plc;
|
||||
_plcDead = false;
|
||||
_connectBackoff.RecordSuccess();
|
||||
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
|
||||
|
||||
// Parse every tag's address once at init so config typos fail fast here instead
|
||||
// of surfacing as BadInternalError on every Read against the bad tag. The parser
|
||||
@@ -310,8 +317,8 @@ public sealed class S7Driver
|
||||
|
||||
_initialized = true;
|
||||
Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}",
|
||||
_driverInstanceId, _options.Host, _options.CpuType, _tagsByRawPath.Count);
|
||||
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
|
||||
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count);
|
||||
|
||||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||
@@ -332,6 +339,7 @@ public sealed class S7Driver
|
||||
// the TcpClient. Dispose is best-effort and idempotent.
|
||||
try { Plc?.Dispose(); } catch { }
|
||||
Plc = null;
|
||||
Volatile.Write(ref _negotiatedPduSize, 0);
|
||||
_initialized = false;
|
||||
_plcDead = false;
|
||||
Health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
@@ -360,7 +368,18 @@ public sealed class S7Driver
|
||||
// disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate
|
||||
// after this returns.
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
await ReleaseConnectionAsync().ConfigureAwait(false);
|
||||
_writeFault = null;
|
||||
_connectBackoff.RecordSuccess();
|
||||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The
|
||||
/// connection part of <see cref="ShutdownAsync"/> (everything but the poll engine and health).
|
||||
/// </summary>
|
||||
private async Task ReleaseConnectionAsync()
|
||||
{
|
||||
var probeCts = _probeCts;
|
||||
var probeTask = _probeTask;
|
||||
try { probeCts?.Cancel(); } catch { }
|
||||
@@ -379,13 +398,27 @@ public sealed class S7Driver
|
||||
_probeCts = null;
|
||||
_probeTask = null;
|
||||
|
||||
_initialized = false;
|
||||
_plcDead = false;
|
||||
_writeFault = null;
|
||||
_connectBackoff.RecordSuccess();
|
||||
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||||
// Swap the connection out under _gate so a Read/Write/poll pass that already holds the gate (they
|
||||
// re-read Plc inside it) can never see a disposed Plc mid-swap. Bounded: a wedged holder must not hang
|
||||
// teardown, in which case we proceed exactly as the un-gated path always did.
|
||||
var gated = false;
|
||||
try { gated = await _gate.WaitAsync(DrainTimeout).ConfigureAwait(false); } catch { }
|
||||
if (!gated)
|
||||
_logger.LogWarning(
|
||||
"S7 driver {DriverInstanceId}: disposing the S7 connection while an operation still holds the gate (waited {Timeout})",
|
||||
_driverInstanceId, DrainTimeout);
|
||||
try
|
||||
{
|
||||
_initialized = false;
|
||||
_plcDead = false;
|
||||
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (gated) { try { _gate.Release(); } catch (ObjectDisposedException) { } }
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -411,6 +444,25 @@ public sealed class S7Driver
|
||||
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
|
||||
/// <see cref="RejectUnsupportedTagConfigs"/>.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first
|
||||
/// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is
|
||||
/// being replaced. Surfaced through <see cref="GetDiagnostics"/> and the connect log line.
|
||||
/// </summary>
|
||||
public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize);
|
||||
|
||||
private int _negotiatedPduSize;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-specific diagnostics key/value pairs (currently <c>S7.NegotiatedPduSize</c>). The core
|
||||
/// <see cref="DriverHealth"/> record carries no diagnostics bag, so this is the surface until one exists.
|
||||
/// </summary>
|
||||
/// <returns>A snapshot of the diagnostics.</returns>
|
||||
public IReadOnlyDictionary<string, object> GetDiagnostics() => new Dictionary<string, object>
|
||||
{
|
||||
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
|
||||
};
|
||||
|
||||
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
|
||||
{
|
||||
S7DataType.Int64,
|
||||
@@ -418,6 +470,14 @@ public sealed class S7Driver
|
||||
S7DataType.Float64,
|
||||
S7DataType.String,
|
||||
S7DataType.DateTime,
|
||||
S7DataType.WString,
|
||||
S7DataType.Char,
|
||||
S7DataType.WChar,
|
||||
S7DataType.Dtl,
|
||||
S7DataType.Date,
|
||||
S7DataType.Tod,
|
||||
S7DataType.Time,
|
||||
S7DataType.S5Time,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -1199,6 +1259,11 @@ public sealed class S7Driver
|
||||
case NotSupportedException:
|
||||
return StatusBadNotSupported;
|
||||
|
||||
case S7ValueRangeException:
|
||||
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
|
||||
// problem, not a transport fault — no health change, no connection teardown.
|
||||
return StatusBadOutOfRange;
|
||||
|
||||
case PlcException pex when IsAccessDenied(pex):
|
||||
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
|
||||
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
|
||||
@@ -1312,8 +1377,7 @@ public sealed class S7Driver
|
||||
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
|
||||
internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
|
||||
addr.Area is S7Area.Timer or S7Area.Counter
|
||||
|| tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
|
||||
or S7DataType.String or S7DataType.DateTime;
|
||||
|| ByteAnchoredDataTypes.Contains(tag.DataType);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
|
||||
@@ -1363,6 +1427,11 @@ public sealed class S7Driver
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
|
||||
S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL).
|
||||
S7DataType.String => tag.StringLength + 2,
|
||||
S7DataType.WString => S7ValueCodecs.WStringWidth(tag.StringLength),
|
||||
S7DataType.Char => 1,
|
||||
S7DataType.WChar or S7DataType.Date or S7DataType.S5Time => 2,
|
||||
S7DataType.Tod or S7DataType.Time => 4,
|
||||
S7DataType.Dtl => 12,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
|
||||
};
|
||||
@@ -1419,6 +1488,17 @@ public sealed class S7Driver
|
||||
// System.DateTime explicitly (the cast pins the box type, consistent with the numeric arms).
|
||||
S7DataType.DateTime => (object)global::S7.Net.Types.DateTime.FromByteArray(block),
|
||||
|
||||
// The codecs below live in S7ValueCodecs (pure, big-endian); a malformed PLC buffer throws
|
||||
// S7ValueRangeException → BadOutOfRange on the read. TOD/TIME/S5TIME surface as Int32 ms.
|
||||
S7DataType.WString => S7ValueCodecs.DecodeWString(block),
|
||||
S7DataType.Char => S7ValueCodecs.DecodeChar(block),
|
||||
S7DataType.WChar => S7ValueCodecs.DecodeWChar(block),
|
||||
S7DataType.Dtl => (object)S7ValueCodecs.DecodeDtl(block),
|
||||
S7DataType.Date => (object)S7ValueCodecs.DecodeDate(block),
|
||||
S7DataType.Tod => (object)S7ValueCodecs.DecodeTod(block),
|
||||
S7DataType.Time => (object)S7ValueCodecs.DecodeTime(block),
|
||||
S7DataType.S5Time => (object)S7ValueCodecs.DecodeS5Time(block),
|
||||
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
|
||||
$"'{tag.Address}' parsed as Size={addr.Size}"),
|
||||
@@ -1484,6 +1564,16 @@ public sealed class S7Driver
|
||||
// System.DateTime or a parseable timestamp string).
|
||||
return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));
|
||||
|
||||
// Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp.
|
||||
case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength);
|
||||
case S7DataType.Char: return S7ValueCodecs.EncodeChar(value);
|
||||
case S7DataType.WChar: return S7ValueCodecs.EncodeWChar(value);
|
||||
case S7DataType.Dtl: return S7ValueCodecs.EncodeDtl(value);
|
||||
case S7DataType.Date: return S7ValueCodecs.EncodeDate(value);
|
||||
case S7DataType.Tod: return S7ValueCodecs.EncodeTod(value);
|
||||
case S7DataType.Time: return S7ValueCodecs.EncodeTime(value);
|
||||
case S7DataType.S5Time: return S7ValueCodecs.EncodeS5Time(value);
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')");
|
||||
@@ -1596,6 +1686,14 @@ public sealed class S7Driver
|
||||
case (S7DataType.Float64, _):
|
||||
case (S7DataType.String, _):
|
||||
case (S7DataType.DateTime, _):
|
||||
case (S7DataType.WString, _):
|
||||
case (S7DataType.Char, _):
|
||||
case (S7DataType.WChar, _):
|
||||
case (S7DataType.Dtl, _):
|
||||
case (S7DataType.Date, _):
|
||||
case (S7DataType.Tod, _):
|
||||
case (S7DataType.Time, _):
|
||||
case (S7DataType.S5Time, _):
|
||||
throw new NotSupportedException(
|
||||
$"S7 array reads of {tag.DataType} land in a follow-up PR");
|
||||
|
||||
@@ -1811,6 +1909,12 @@ public sealed class S7Driver
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotSupported);
|
||||
}
|
||||
catch (S7ValueRangeException)
|
||||
{
|
||||
// Value unrepresentable in the S7 type (string over its declared max, DATE < 1990…):
|
||||
// refused with nothing sent — a client error, no health impact.
|
||||
results[i] = new WriteResult(StatusBadOutOfRange);
|
||||
}
|
||||
catch (S7WriteValueException)
|
||||
{
|
||||
// Client-supplied value of the wrong shape — not a device or comms fault.
|
||||
@@ -1989,6 +2093,14 @@ public sealed class S7Driver
|
||||
case (S7DataType.Float64, _):
|
||||
case (S7DataType.String, _):
|
||||
case (S7DataType.DateTime, _):
|
||||
case (S7DataType.WString, _):
|
||||
case (S7DataType.Char, _):
|
||||
case (S7DataType.WChar, _):
|
||||
case (S7DataType.Dtl, _):
|
||||
case (S7DataType.Date, _):
|
||||
case (S7DataType.Tod, _):
|
||||
case (S7DataType.Time, _):
|
||||
case (S7DataType.S5Time, _):
|
||||
throw new NotSupportedException($"S7 array writes of {tag.DataType} are not supported (tag '{tag.Name}').");
|
||||
case (S7DataType.Byte, S7Size.Byte):
|
||||
case (S7DataType.UInt16, S7Size.Word):
|
||||
@@ -2089,6 +2201,9 @@ public sealed class S7Driver
|
||||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||||
S7DataType.String => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||||
S7DataType.WString or S7DataType.Char or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date
|
||||
or S7DataType.Tod or S7DataType.Time or S7DataType.S5Time
|
||||
=> throw new NotSupportedException($"S7 {dataType} is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||||
_ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
|
||||
};
|
||||
|
||||
@@ -2141,8 +2256,7 @@ public sealed class S7Driver
|
||||
throw new InvalidOperationException(
|
||||
$"S7 reconnect to {_options.Host}:{_options.Port} suppressed — backing off after a failed connect attempt.");
|
||||
|
||||
var plc = _plcFactory.Create(
|
||||
_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
|
||||
var plc = _plcFactory.Create(_options);
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -2172,8 +2286,9 @@ public sealed class S7Driver
|
||||
|
||||
_connectBackoff.RecordSuccess();
|
||||
Plc = plc;
|
||||
_logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}",
|
||||
_driverInstanceId, _options.Host, _options.CpuType);
|
||||
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
|
||||
_logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize}",
|
||||
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize);
|
||||
return plc;
|
||||
}
|
||||
|
||||
@@ -2249,7 +2364,7 @@ public sealed class S7Driver
|
||||
/// no typed error code for it, so the inner message is the only discriminator
|
||||
/// S7.Net exposes.
|
||||
/// </summary>
|
||||
private static bool IsAccessDenied(PlcException pex)
|
||||
internal static bool IsAccessDenied(PlcException pex)
|
||||
{
|
||||
for (Exception? e = pex; e is not null; e = e.InnerException)
|
||||
{
|
||||
@@ -2306,8 +2421,10 @@ public sealed class S7Driver
|
||||
S7DataType.UInt64 => DriverDataType.UInt64,
|
||||
S7DataType.Float32 => DriverDataType.Float32,
|
||||
S7DataType.Float64 => DriverDataType.Float64,
|
||||
S7DataType.String => DriverDataType.String,
|
||||
S7DataType.DateTime => DriverDataType.DateTime,
|
||||
S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar => DriverDataType.String,
|
||||
S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date => DriverDataType.DateTime,
|
||||
// TOD / TIME / S5TIME surface as Int32 milliseconds (the default arm; named for the reader).
|
||||
S7DataType.Tod or S7DataType.Time or S7DataType.S5Time => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
@@ -2492,6 +2609,7 @@ public sealed class S7Driver
|
||||
_connectBackoff.RecordSuccess();
|
||||
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
|
||||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +87,23 @@ public static class S7DriverFactoryExtensions
|
||||
$"S7 driver config for '{driverInstanceId}' has SpanReadMaxGapBytes {spanReadMaxGapBytes}; " +
|
||||
"must be >= -1 — -1 disables span grouping and 0 merges only strictly adjacent tags.");
|
||||
|
||||
// Optional TSAP / connection-type override. Validated here (the single choke point) so a typo'd
|
||||
// TSAP fails the deploy rather than silently connecting with the slot-derived default.
|
||||
var localTsap = NormalizeTsap(dto.LocalTsap, driverInstanceId, "LocalTsap");
|
||||
var remoteTsap = NormalizeTsap(dto.RemoteTsap, driverInstanceId, "RemoteTsap");
|
||||
S7ConnectionType? connectionType = string.IsNullOrWhiteSpace(dto.ConnectionType)
|
||||
? null
|
||||
: ParseEnum<S7ConnectionType>(dto.ConnectionType, driverInstanceId, "ConnectionType");
|
||||
if (remoteTsap is not null && connectionType is not null)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 driver config for '{driverInstanceId}' {S7DriverOptions.TsapConflictDetail}");
|
||||
|
||||
return new S7DriverOptions
|
||||
{
|
||||
Host = dto.Host!,
|
||||
LocalTsap = localTsap,
|
||||
RemoteTsap = remoteTsap,
|
||||
ConnectionType = connectionType,
|
||||
Port = dto.Port ?? 102,
|
||||
CpuType = ParseEnum<S7CpuType>(dto.CpuType, driverInstanceId, "CpuType",
|
||||
fallback: S7CpuType.S71500),
|
||||
@@ -115,6 +129,23 @@ public static class S7DriverFactoryExtensions
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeTsap(JsonElement? raw, string driverInstanceId, string field)
|
||||
{
|
||||
if (raw is not { } el || el.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return null;
|
||||
string? text = el.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => el.GetString(),
|
||||
JsonValueKind.Number when el.TryGetUInt16(out var n) => S7Tsap.Format(n),
|
||||
_ => "?",
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
if (!S7Tsap.TryParse(text, out var tsap))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 driver config for '{driverInstanceId}' has {field} '{text}'; expected two hex bytes " +
|
||||
"such as \"01.00\" or \"03.01\" (or a 16-bit number).");
|
||||
return S7Tsap.Format(tsap);
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
|
||||
string? tagName = null, T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
@@ -151,6 +182,12 @@ public static class S7DriverFactoryExtensions
|
||||
public short? Rack { get; init; }
|
||||
/// <summary>Gets the slot number.</summary>
|
||||
public short? Slot { get; init; }
|
||||
/// <summary>Gets the local TSAP override — hex text (<c>"01.00"</c>) or a 16-bit number.</summary>
|
||||
public JsonElement? LocalTsap { get; init; }
|
||||
/// <summary>Gets the remote TSAP override — hex text (<c>"03.01"</c>) or a 16-bit number.</summary>
|
||||
public JsonElement? RemoteTsap { get; init; }
|
||||
/// <summary>Gets the connection-type name (PG / OP / S7Basic); a string so AdminUI-authored configs bind.</summary>
|
||||
public string? ConnectionType { get; init; }
|
||||
/// <summary>Gets the connection timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
/// <summary>Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy
|
||||
|
||||
@@ -66,7 +66,11 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
// The Plc is opened and immediately closed — no reads or writes are performed.
|
||||
// Use a linked CTS so we can distinguish a real caller cancellation from
|
||||
// S7netplus's internal socket-read timeout (which also surfaces as OCE).
|
||||
var plc = new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
|
||||
// An authored TSAP / connection-type override applies to Test Connect too — otherwise the probe
|
||||
// would succeed (or fail) on a different handshake than the driver will use.
|
||||
var plc = S7Tsap.Resolve(opts) is { } tsap
|
||||
? new Plc(host, port, tsap)
|
||||
: new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
|
||||
plc.ReadTimeout = (int)timeout.TotalMilliseconds;
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
handshakeCts.CancelAfter(timeout);
|
||||
@@ -76,7 +80,15 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
sw.Stop();
|
||||
|
||||
if (plc.IsConnected)
|
||||
return new(true, $"S7 connected (CPU {opts.CpuType})", sw.Elapsed);
|
||||
{
|
||||
// A handshake alone passes on a CPU with PUT/GET disabled (S7-1200/1500 default) — every
|
||||
// later read would fail. One minimal read proves data access.
|
||||
if (await CheckDataAccessAsync(
|
||||
t => plc.ReadBytesAsync(global::S7.Net.DataType.Memory, 0, 0, 2, t), handshakeCts.Token)
|
||||
is { } denied)
|
||||
return denied;
|
||||
return new(true, $"S7 connected (CPU {opts.CpuType}, PDU {plc.MaxPDUSize} bytes)", sw.Elapsed);
|
||||
}
|
||||
|
||||
return new(false, $"Reachable at {host}:{port} but S7 handshake failed: not connected", null);
|
||||
}
|
||||
@@ -100,6 +112,38 @@ public sealed class S7DriverProbe : IDriverProbe
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one minimal read after the handshake. Returns null when data access works — including when the
|
||||
/// CPU merely rejects that particular address (MB0 not present / out of range: the connection is fine).
|
||||
/// PUT/GET-disabled yields the actionable message; any other fault is a probe failure with its message.
|
||||
/// </summary>
|
||||
/// <param name="read">The minimal read.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A failure result, or null on success.</returns>
|
||||
internal static async Task<DriverProbeResult?> CheckDataAccessAsync(Func<CancellationToken, Task<byte[]>> read, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await read(ct).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
|
||||
{
|
||||
return new(false,
|
||||
"S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
|
||||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null);
|
||||
}
|
||||
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
|
||||
{
|
||||
return null; // address not available on this CPU — not a connection problem
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, $"S7 connected but a test read failed: {ex.Message}", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
|
||||
=> (opts.Host, opts.Port);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Globalization;
|
||||
using S7.Net.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// ISO-TSAP helpers for the optional <c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c>
|
||||
/// config: parsing the hex text form and resolving the effective <see cref="TsapPair"/> handed to
|
||||
/// S7.Net. Pure — no I/O — so the resolution rules are unit-testable without a PLC.
|
||||
/// </summary>
|
||||
internal static class S7Tsap
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a TSAP written as two hex bytes — <c>"01.00"</c>, <c>"03:01"</c> (1–2 digits per byte) — or
|
||||
/// as one 16-bit hex value, <c>"0100"</c> / <c>"0x0301"</c>. High byte first.
|
||||
/// </summary>
|
||||
/// <param name="text">The configured text.</param>
|
||||
/// <param name="tsap">The 16-bit TSAP (<c>first << 8 | second</c>).</param>
|
||||
/// <returns>True when <paramref name="text"/> is a valid TSAP.</returns>
|
||||
internal static bool TryParse(string? text, out ushort tsap)
|
||||
{
|
||||
tsap = 0;
|
||||
if (string.IsNullOrWhiteSpace(text)) return false;
|
||||
var t = text.Trim();
|
||||
var sep = t.IndexOfAny(['.', ':', '-']);
|
||||
if (sep >= 0)
|
||||
{
|
||||
if (!TryHexByte(t[..sep], out var hi) || !TryHexByte(t[(sep + 1)..], out var lo)) return false;
|
||||
tsap = (ushort)((hi << 8) | lo);
|
||||
return true;
|
||||
}
|
||||
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..];
|
||||
if (t.Length is < 1 or > 4) return false;
|
||||
return ushort.TryParse(t, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out tsap);
|
||||
}
|
||||
|
||||
private static bool TryHexByte(string s, out byte b)
|
||||
{
|
||||
b = 0;
|
||||
s = s.Trim();
|
||||
return s.Length is >= 1 and <= 2
|
||||
&& byte.TryParse(s, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out b);
|
||||
}
|
||||
|
||||
/// <summary>Formats a 16-bit TSAP as canonical <c>"XX.YY"</c> (upper-case hex).</summary>
|
||||
/// <param name="tsap">The 16-bit TSAP.</param>
|
||||
/// <returns>The canonical text.</returns>
|
||||
internal static string Format(ushort tsap) => $"{tsap >> 8:X2}.{tsap & 0xFF:X2}";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the TSAP pair to connect with, or <c>null</c> when the options carry no override (the
|
||||
/// caller then keeps the plain <c>Plc(cpu, host, port, rack, slot)</c> construction, unchanged).
|
||||
/// Starts from S7.Net's own per-CPU default for <paramref name="options"/>'s rack/slot, then applies,
|
||||
/// in order: <c>LocalTsap</c> replaces the local TSAP; <c>RemoteTsap</c> replaces the remote TSAP;
|
||||
/// <c>ConnectionType</c> replaces only the remote TSAP's first byte.
|
||||
/// </summary>
|
||||
/// <param name="options">The driver options.</param>
|
||||
/// <returns>The effective pair, or null for "no override".</returns>
|
||||
internal static TsapPair? Resolve(S7DriverOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var hasLocal = !string.IsNullOrWhiteSpace(options.LocalTsap);
|
||||
var hasRemote = !string.IsNullOrWhiteSpace(options.RemoteTsap);
|
||||
if (!hasLocal && !hasRemote && options.ConnectionType is null) return null;
|
||||
|
||||
var def = TsapPair.GetDefaultTsapPair(S7CpuTypeMap.ToS7Net(options.CpuType), options.Rack, options.Slot);
|
||||
var local = def.Local;
|
||||
var remote = def.Remote;
|
||||
|
||||
if (hasLocal)
|
||||
{
|
||||
if (!TryParse(options.LocalTsap, out var l))
|
||||
throw new FormatException($"S7 LocalTsap '{options.LocalTsap}' is not a valid TSAP (expected e.g. \"01.00\").");
|
||||
local = new Tsap((byte)(l >> 8), (byte)(l & 0xFF));
|
||||
}
|
||||
if (hasRemote)
|
||||
{
|
||||
if (!TryParse(options.RemoteTsap, out var r))
|
||||
throw new FormatException($"S7 RemoteTsap '{options.RemoteTsap}' is not a valid TSAP (expected e.g. \"03.01\").");
|
||||
remote = new Tsap((byte)(r >> 8), (byte)(r & 0xFF));
|
||||
}
|
||||
else if (options.ConnectionType is { } ct)
|
||||
{
|
||||
remote = new Tsap((byte)ct, remote.SecondByte);
|
||||
}
|
||||
return new TsapPair(local, remote);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// A value the S7 codecs refuse because it is outside what the S7 type can represent —
|
||||
/// on the WRITE side a caller value that overflows the declared field (a string longer than its
|
||||
/// declared max length, a DATE before 1990, a TOD past midnight), on the READ side a PLC buffer
|
||||
/// that does not decode (uninitialised DTL, a non-BCD S5TIME digit, a string whose current length
|
||||
/// exceeds its reserved length). Maps to <c>BadOutOfRange</c> and, unlike a transport fault, does
|
||||
/// not degrade driver health or condemn the connection.
|
||||
/// </summary>
|
||||
internal sealed class S7ValueRangeException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Pure byte-level codecs for the S7 string, character and date/time/duration types the narrow
|
||||
/// (1/2/4-byte) read path cannot express: <c>WSTRING</c>, <c>CHAR</c>, <c>WCHAR</c>, <c>DTL</c>,
|
||||
/// <c>DATE</c>, <c>TOD</c>, <c>TIME</c> and <c>S5TIME</c>. Every method works on a raw big-endian S7
|
||||
/// block, so each wire format is locked by known-byte-vector unit tests without a PLC. Classic
|
||||
/// <c>STRING</c> and <c>DATE_AND_TIME</c> (DT) stay on S7.Net's own codecs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>OPC UA mapping</b> (no new <c>DriverDataType</c>): WString/Char/WChar → String;
|
||||
/// DTL/Date → DateTime; TOD, TIME, S5TIME → Int32 <b>milliseconds</b> (TOD = ms since midnight,
|
||||
/// TIME = signed duration, S5TIME = duration). All values fit Int32.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Writes refuse rather than clamp or truncate</b>: an unrepresentable value throws
|
||||
/// <see cref="S7ValueRangeException"/> (→ <c>BadOutOfRange</c>) with nothing sent to the PLC; a value
|
||||
/// of the wrong shape throws <see cref="S7Driver.S7WriteValueException"/> (→ <c>BadTypeMismatch</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7ValueCodecs
|
||||
{
|
||||
/// <summary>Largest declared WSTRING character count (S7-1500 limit; header is UInt16 but the PDU-safe ceiling is 16382).</summary>
|
||||
internal const int MaxWStringLength = 16382;
|
||||
|
||||
/// <summary>First day of the S7 DATE epoch.</summary>
|
||||
private static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
|
||||
|
||||
private const int MillisecondsPerDay = 86_400_000;
|
||||
|
||||
// ---- CHAR / WCHAR ----
|
||||
|
||||
/// <summary>Decodes a 1-byte S7 CHAR (Latin-1) to a one-character string.</summary>
|
||||
internal static string DecodeChar(ReadOnlySpan<byte> block)
|
||||
{
|
||||
RequireLength(block, 1, "CHAR");
|
||||
return ((char)block[0]).ToString();
|
||||
}
|
||||
|
||||
/// <summary>Encodes a one-character string to a 1-byte S7 CHAR. Characters above U+00FF are out of range.</summary>
|
||||
internal static byte[] EncodeChar(object? value)
|
||||
{
|
||||
var c = SingleChar(value, "CHAR");
|
||||
if (c > 0xFF)
|
||||
throw new S7ValueRangeException($"S7 CHAR holds one 8-bit character; U+{(int)c:X4} is out of range.");
|
||||
return [(byte)c];
|
||||
}
|
||||
|
||||
/// <summary>Decodes a 2-byte S7 WCHAR (UTF-16 big-endian code unit) to a one-character string.</summary>
|
||||
internal static string DecodeWChar(ReadOnlySpan<byte> block)
|
||||
{
|
||||
RequireLength(block, 2, "WCHAR");
|
||||
return ((char)BinaryPrimitives.ReadUInt16BigEndian(block)).ToString();
|
||||
}
|
||||
|
||||
/// <summary>Encodes a one-character string to a 2-byte S7 WCHAR (UTF-16 big-endian).</summary>
|
||||
internal static byte[] EncodeWChar(object? value)
|
||||
{
|
||||
var b = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(b, SingleChar(value, "WCHAR"));
|
||||
return b;
|
||||
}
|
||||
|
||||
private static char SingleChar(object? value, string type)
|
||||
{
|
||||
if (value is char ch) return ch;
|
||||
var s = value as string ?? throw new S7Driver.S7WriteValueException($"S7 {type} write needs a one-character string.");
|
||||
if (s.Length != 1)
|
||||
throw new S7Driver.S7WriteValueException(
|
||||
$"S7 {type} write needs exactly one character; got {s.Length}.");
|
||||
return s[0];
|
||||
}
|
||||
|
||||
// ---- WSTRING ----
|
||||
|
||||
/// <summary>Byte width of a WSTRING reserving <paramref name="maxLength"/> characters: 4-byte header + 2 bytes per character.</summary>
|
||||
internal static int WStringWidth(int maxLength) => 4 + (2 * maxLength);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes <c>[maxLen UInt16 BE][curLen UInt16 BE][curLen × UTF-16BE]</c>. Characters past
|
||||
/// <c>curLen</c> (the reserved padding) are ignored.
|
||||
/// </summary>
|
||||
internal static string DecodeWString(ReadOnlySpan<byte> block)
|
||||
{
|
||||
if (block.Length < 4)
|
||||
throw new S7ValueRangeException($"S7 WSTRING block is {block.Length} bytes; the header alone is 4.");
|
||||
var maxLen = BinaryPrimitives.ReadUInt16BigEndian(block);
|
||||
var curLen = BinaryPrimitives.ReadUInt16BigEndian(block[2..]);
|
||||
if (curLen > maxLen)
|
||||
throw new S7ValueRangeException($"S7 WSTRING current length {curLen} exceeds its reserved length {maxLen}.");
|
||||
if (4 + (2 * curLen) > block.Length)
|
||||
throw new S7ValueRangeException(
|
||||
$"S7 WSTRING current length {curLen} runs past the {block.Length}-byte block.");
|
||||
return Encoding.BigEndianUnicode.GetString(block.Slice(4, 2 * curLen));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a string into the full reserved WSTRING field (<see cref="WStringWidth"/> bytes, zero
|
||||
/// padded). A value with more UTF-16 code units than <paramref name="maxLength"/> is refused, not truncated.
|
||||
/// </summary>
|
||||
internal static byte[] EncodeWString(object? value, int maxLength)
|
||||
{
|
||||
var s = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? "";
|
||||
if (s.Length > maxLength)
|
||||
throw new S7ValueRangeException(
|
||||
$"S7 WSTRING value is {s.Length} characters; the declared maximum is {maxLength}.");
|
||||
var b = new byte[WStringWidth(maxLength)];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)maxLength);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(2), (ushort)s.Length);
|
||||
Encoding.BigEndianUnicode.GetBytes(s, b.AsSpan(4));
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---- DTL (12 bytes) ----
|
||||
|
||||
/// <summary>
|
||||
/// Decodes DTL: <c>year UInt16 BE, month, day, weekday, hour, minute, second, nanoseconds UInt32 BE</c>.
|
||||
/// The weekday byte is ignored. An all-zero (uninitialised) buffer is refused.
|
||||
/// </summary>
|
||||
internal static DateTime DecodeDtl(ReadOnlySpan<byte> block)
|
||||
{
|
||||
RequireLength(block, 12, "DTL");
|
||||
var year = BinaryPrimitives.ReadUInt16BigEndian(block);
|
||||
var nanos = BinaryPrimitives.ReadUInt32BigEndian(block[8..]);
|
||||
if (year == 0 || block[2] == 0 || block[3] == 0)
|
||||
throw new S7ValueRangeException("S7 DTL buffer is uninitialised (year/month/day is zero).");
|
||||
if (nanos > 999_999_999)
|
||||
throw new S7ValueRangeException($"S7 DTL nanoseconds {nanos} out of range.");
|
||||
try
|
||||
{
|
||||
return new DateTime(year, block[2], block[3], block[5], block[6], block[7], DateTimeKind.Unspecified)
|
||||
.AddTicks(nanos / 100);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
throw new S7ValueRangeException($"S7 DTL buffer is not a valid date/time: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Encodes a DateTime to DTL. Years outside the PLC's 1970–2262 range are refused; sub-100 ns precision is not representable in a DateTime so nanoseconds are a multiple of 100.</summary>
|
||||
internal static byte[] EncodeDtl(object? value)
|
||||
{
|
||||
var dt = ToDateTime(value, "DTL");
|
||||
if (dt.Year is < 1970 or > 2262)
|
||||
throw new S7ValueRangeException($"S7 DTL year {dt.Year} is outside 1970–2262.");
|
||||
var b = new byte[12];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)dt.Year);
|
||||
b[2] = (byte)dt.Month;
|
||||
b[3] = (byte)dt.Day;
|
||||
b[4] = (byte)((int)dt.DayOfWeek + 1); // S7: 1 = Sunday … 7 = Saturday
|
||||
b[5] = (byte)dt.Hour;
|
||||
b[6] = (byte)dt.Minute;
|
||||
b[7] = (byte)dt.Second;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(8), (uint)((dt.Ticks % TimeSpan.TicksPerSecond) * 100));
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---- DATE (UInt16 days since 1990-01-01) ----
|
||||
|
||||
/// <summary>Decodes DATE (days since 1990-01-01, UInt16 BE) to a midnight DateTime.</summary>
|
||||
internal static DateTime DecodeDate(ReadOnlySpan<byte> block)
|
||||
{
|
||||
RequireLength(block, 2, "DATE");
|
||||
return DateEpoch.AddDays(BinaryPrimitives.ReadUInt16BigEndian(block));
|
||||
}
|
||||
|
||||
/// <summary>Encodes the DATE part of a DateTime; dates before 1990-01-01 or past 2169-06-06 (the UInt16 ceiling) are refused. The time-of-day is dropped.</summary>
|
||||
internal static byte[] EncodeDate(object? value)
|
||||
{
|
||||
var days = (ToDateTime(value, "DATE").Date - DateEpoch).Days;
|
||||
if (days is < 0 or > ushort.MaxValue)
|
||||
throw new S7ValueRangeException("S7 DATE must be between 1990-01-01 and 2169-06-06 (UInt16 days since 1990-01-01).");
|
||||
var b = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)days);
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---- TOD (UInt32 ms since midnight) ----
|
||||
|
||||
/// <summary>Decodes TIME_OF_DAY to Int32 milliseconds since midnight (0–86 399 999).</summary>
|
||||
internal static int DecodeTod(ReadOnlySpan<byte> block)
|
||||
{
|
||||
RequireLength(block, 4, "TOD");
|
||||
var ms = BinaryPrimitives.ReadUInt32BigEndian(block);
|
||||
if (ms >= MillisecondsPerDay)
|
||||
throw new S7ValueRangeException($"S7 TOD value {ms} ms is past midnight (max 86399999).");
|
||||
return (int)ms;
|
||||
}
|
||||
|
||||
/// <summary>Encodes milliseconds since midnight (or a <see cref="TimeSpan"/>) to TOD; outside 0–86 399 999 is refused.</summary>
|
||||
internal static byte[] EncodeTod(object? value)
|
||||
{
|
||||
var ms = ToMilliseconds(value, "TOD");
|
||||
if (ms is < 0 or >= MillisecondsPerDay)
|
||||
throw new S7ValueRangeException($"S7 TOD value {ms} ms is outside 0–86399999.");
|
||||
var b = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(b, (uint)ms);
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---- TIME (Int32 ms, signed) ----
|
||||
|
||||
/// <summary>Decodes IEC TIME to a signed Int32 millisecond duration.</summary>
|
||||
internal static int DecodeTime(ReadOnlySpan<byte> block)
|
||||
{
|
||||
RequireLength(block, 4, "TIME");
|
||||
return BinaryPrimitives.ReadInt32BigEndian(block);
|
||||
}
|
||||
|
||||
/// <summary>Encodes a signed millisecond duration (or <see cref="TimeSpan"/>) to TIME; outside the Int32 range is refused.</summary>
|
||||
internal static byte[] EncodeTime(object? value)
|
||||
{
|
||||
var ms = ToMilliseconds(value, "TIME");
|
||||
if (ms is < int.MinValue or > int.MaxValue)
|
||||
throw new S7ValueRangeException($"S7 TIME value {ms} ms is outside the Int32 range.");
|
||||
var b = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(b, (int)ms);
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---- S5TIME (2 bytes) ----
|
||||
|
||||
private static readonly int[] S5BaseMs = [10, 100, 1000, 10_000];
|
||||
|
||||
/// <summary>
|
||||
/// Decodes S5TIME — bits 13–12 select the time base (00 = 10 ms, 01 = 100 ms, 10 = 1 s, 11 = 10 s),
|
||||
/// bits 11–0 are three BCD digits — to Int32 milliseconds. Bits 15–14 are ignored. A non-BCD nibble is refused.
|
||||
/// </summary>
|
||||
internal static int DecodeS5Time(ReadOnlySpan<byte> block)
|
||||
{
|
||||
RequireLength(block, 2, "S5TIME");
|
||||
int d2 = block[0] & 0x0F, d1 = block[1] >> 4, d0 = block[1] & 0x0F;
|
||||
if (d2 > 9 || d1 > 9 || d0 > 9)
|
||||
throw new S7ValueRangeException("S7 S5TIME holds a non-BCD digit.");
|
||||
var value = (d2 * 100) + (d1 * 10) + d0;
|
||||
return value * S5BaseMs[(block[0] >> 4) & 0x03];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a millisecond duration to S5TIME using the finest time base that holds it (value ≤ 999).
|
||||
/// Refused when it exceeds 9 990 000 ms or is not an exact multiple of a base that fits — no rounding.
|
||||
/// </summary>
|
||||
internal static byte[] EncodeS5Time(object? value)
|
||||
{
|
||||
var ms = ToMilliseconds(value, "S5TIME");
|
||||
if (ms < 0 || ms > 999L * 10_000)
|
||||
throw new S7ValueRangeException($"S7 S5TIME value {ms} ms is outside 0–9990000.");
|
||||
for (var baseCode = 0; baseCode < S5BaseMs.Length; baseCode++)
|
||||
{
|
||||
var unit = S5BaseMs[baseCode];
|
||||
if (ms % unit != 0 || ms / unit > 999) continue;
|
||||
var v = (int)(ms / unit);
|
||||
return [(byte)((baseCode << 4) | (v / 100)), (byte)(((v / 10 % 10) << 4) | (v % 10))];
|
||||
}
|
||||
throw new S7ValueRangeException(
|
||||
$"S7 S5TIME cannot represent {ms} ms exactly (resolution is 10 ms up to 9990 ms, coarser above).");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static void RequireLength(ReadOnlySpan<byte> block, int expected, string type)
|
||||
{
|
||||
if (block.Length < expected)
|
||||
throw new S7ValueRangeException($"S7 {type} block is {block.Length} bytes; expected {expected}.");
|
||||
}
|
||||
|
||||
private static DateTime ToDateTime(object? value, string type)
|
||||
{
|
||||
try { return value is DateTimeOffset dto ? dto.DateTime : Convert.ToDateTime(value, System.Globalization.CultureInfo.InvariantCulture); }
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
throw new S7Driver.S7WriteValueException($"S7 {type} write needs a date/time value ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
// Fractional input is rounded (Convert.ToInt64, banker's), matching the driver's existing Int32 write
|
||||
// path (BoxValueForWrite → Convert.ToInt32). "Refused, not rounded" applies to S5TIME's time-base grid.
|
||||
private static long ToMilliseconds(object? value, string type)
|
||||
{
|
||||
try { return value is TimeSpan ts ? (long)ts.TotalMilliseconds : Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); }
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
throw new S7Driver.S7WriteValueException($"S7 {type} write needs a millisecond count ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,9 @@ public enum TwinCATDataType
|
||||
String, // ASCII string
|
||||
WString,// UTF-16 string
|
||||
// IEC 61131-3 / TwinCAT temporal types — all stored on the wire as 32-bit unsigned (UDINT)
|
||||
// raw counters. The driver surfaces them as DriverDataType.UInt32, so
|
||||
// operators see the raw counter, not a decoded date/duration. Proper decoding to
|
||||
// DriverDataType.DateTime is a future enhancement; until then comments accurately describe
|
||||
// the on-wire encoding so the next implementer doesn't re-derive it wrong.
|
||||
// raw counters. The driver decodes them (#306): TIME / TOD publish Double milliseconds
|
||||
// (DriverDataType.Float64), DATE / DT publish a UTC DateTime (DriverDataType.DateTime). A tag that
|
||||
// wants the raw counter declares UDInt against the same symbol.
|
||||
Time, // TIME — duration in milliseconds, stored as UDINT (32-bit unsigned)
|
||||
Date, // DATE — seconds since 1970-01-01 truncated to a day boundary, stored as UDINT
|
||||
DateTime, // DT (DATE_AND_TIME) — seconds since 1970-01-01, stored as UDINT
|
||||
|
||||
@@ -155,7 +155,10 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, MapAndSignal((uint)result.ErrorCode));
|
||||
|
||||
return (result.Value, TwinCATStatusMapper.Good);
|
||||
return (TwinCATTemporalCodec.IsTemporal(type) && result.Value is uint rawTemporal
|
||||
? TwinCATTemporalCodec.Decode(type, rawTemporal)
|
||||
: result.Value,
|
||||
TwinCATStatusMapper.Good);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
@@ -412,6 +415,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
|
||||
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
|
||||
value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
|
||||
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
|
||||
}
|
||||
|
||||
@@ -696,7 +701,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
_ => typeof(int),
|
||||
};
|
||||
|
||||
private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||
internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool => Convert.ToBoolean(value),
|
||||
TwinCATDataType.SInt => Convert.ToSByte(value),
|
||||
@@ -711,7 +716,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
TwinCATDataType.LReal => Convert.ToDouble(value),
|
||||
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value),
|
||||
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
||||
};
|
||||
|
||||
|
||||
@@ -73,15 +73,28 @@ internal static class TwinCATArrayCodec
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay:
|
||||
case TwinCATDataType.UDInt:
|
||||
{
|
||||
// TIME / DATE / DT / TOD publish their raw 32-bit count — the same CLR shape (uint) a
|
||||
// scalar of those types publishes.
|
||||
var a = new uint[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.Time or TwinCATDataType.TimeOfDay:
|
||||
{
|
||||
// TIME / TOD publish milliseconds as double — the same CLR shape a scalar publishes (#306).
|
||||
var a = new double[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = (double)TwinCATTemporalCodec.Decode(declared, BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]));
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.Date or TwinCATDataType.DateTime:
|
||||
{
|
||||
// DATE / DT publish a UTC DateTime, as a scalar does (#306).
|
||||
var a = new DateTime[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = (DateTime)TwinCATTemporalCodec.Decode(declared, BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]));
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.LInt:
|
||||
{
|
||||
var a = new long[n];
|
||||
|
||||
@@ -22,8 +22,10 @@ public static class TwinCATDataTypeExtensions
|
||||
TwinCATDataType.Real => DriverDataType.Float32,
|
||||
TwinCATDataType.LReal => DriverDataType.Float64,
|
||||
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.UInt32,
|
||||
// TIME / TOD → Double milliseconds, DATE / DT → UTC DateTime (#306); the driver decodes the UDINT
|
||||
// wire counter in AdsTwinCATClient. A tag that wants the raw counter declares UDInt instead.
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Float64,
|
||||
TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime,
|
||||
TwinCATDataType.Structure => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
@@ -110,8 +110,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
public string DriverType => "TwinCAT";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
||||
// runs it with no teardown first). Release the previous device states' probe loops + clients before
|
||||
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
@@ -175,7 +179,30 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels, awaits and disposes every existing device state's probe loop, client and gate, then
|
||||
/// clears the device table. Device part of <see cref="ShutdownAsync"/> only.
|
||||
/// </summary>
|
||||
private async Task ReleaseDeviceStatesAsync()
|
||||
{
|
||||
if (_devices.IsEmpty) return;
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
if (state.ProbeTask is Task pt)
|
||||
{
|
||||
try { await pt.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
|
||||
catch { /* cancelled, faulted or wedged — teardown continues */ }
|
||||
}
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.ProbeTask = null;
|
||||
state.DisposeClient();
|
||||
state.DisposeGate();
|
||||
}
|
||||
_devices.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Converts the IEC 61131-3 temporal types between their 32-bit unsigned (UDINT) wire counters and the
|
||||
/// values the driver publishes (#637 / #306). TIME and TOD surface as a <see cref="double"/> of
|
||||
/// milliseconds (OPC UA <c>Double</c>; no new <c>DriverDataType</c> member — see
|
||||
/// <c>docs/drivers/TwinCAT.md</c>); DATE and DT surface as a UTC <see cref="DateTime"/>.
|
||||
/// </summary>
|
||||
internal static class TwinCATTemporalCodec
|
||||
{
|
||||
private static readonly DateTime Epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>True for TIME / DATE / DT / TOD.</summary>
|
||||
public static bool IsTemporal(TwinCATDataType type) =>
|
||||
type is TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay;
|
||||
|
||||
/// <summary>Decodes a raw UDINT counter into the published value for <paramref name="type"/>.</summary>
|
||||
public static object Decode(TwinCATDataType type, uint raw) => type switch
|
||||
{
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => (double)raw,
|
||||
TwinCATDataType.Date or TwinCATDataType.DateTime => Epoch.AddSeconds(raw),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "not a temporal type"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a published/written value into the UDINT counter. Accepts the native shape
|
||||
/// (<see cref="double"/> ms / <see cref="DateTime"/> / <see cref="TimeSpan"/>) and any other
|
||||
/// numeric as the raw counter, so a client writing an integer keeps working.
|
||||
/// </summary>
|
||||
/// <exception cref="OverflowException">Out of the UDINT range, negative, or NaN/infinite.</exception>
|
||||
/// <exception cref="InvalidCastException">A value of no accepted shape.</exception>
|
||||
public static uint Encode(TwinCATDataType type, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case TwinCATDataType.Time or TwinCATDataType.TimeOfDay:
|
||||
var ms = value switch
|
||||
{
|
||||
TimeSpan ts => ToUInt(ts.TotalMilliseconds),
|
||||
DateTime => throw new InvalidCastException($"{type} takes a duration in milliseconds, not a DateTime"),
|
||||
double d => ToUInt(d),
|
||||
float f => ToUInt(f),
|
||||
_ => Convert.ToUInt32(value),
|
||||
};
|
||||
// TOD is a time of day: a full day or more is not representable.
|
||||
if (type == TwinCATDataType.TimeOfDay && ms >= 86_400_000u)
|
||||
throw new OverflowException($"{ms} ms is not a time of day (< 86 400 000)");
|
||||
return ms;
|
||||
case TwinCATDataType.Date or TwinCATDataType.DateTime:
|
||||
if (value is DateTime dt)
|
||||
{
|
||||
var utc = dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : dt;
|
||||
var secs = ToUInt(Math.Floor((utc - Epoch).TotalSeconds));
|
||||
// DATE is a whole-day counter.
|
||||
return type == TwinCATDataType.Date ? secs - secs % 86400u : secs;
|
||||
}
|
||||
if (value is TimeSpan) throw new InvalidCastException($"{type} takes a DateTime, not a duration");
|
||||
return value switch
|
||||
{
|
||||
double d => ToUInt(d),
|
||||
float f => ToUInt(f),
|
||||
_ => Convert.ToUInt32(value),
|
||||
};
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, "not a temporal type");
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ToUInt(double v)
|
||||
{
|
||||
if (double.IsNaN(v) || double.IsInfinity(v) || v < 0 || v > uint.MaxValue)
|
||||
throw new OverflowException($"{v} is outside the 32-bit unsigned range of an IEC temporal value");
|
||||
return (uint)Math.Round(v, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user