Merge branch 'master' into b626-opcuaclient

This commit is contained in:
Joseph Doherty
2026-10-08 14:47:49 -04:00
72 changed files with 3775 additions and 343 deletions
@@ -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);
}
@@ -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.
@@ -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>&lt;Entry key=…&gt;</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 &lt;&lt; 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);
}
}