Merge branch 'master' into remed/lane-G2

This commit is contained in:
Joseph Doherty
2026-10-22 16:28:15 -04:00
182 changed files with 16202 additions and 3091 deletions
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
/// Any other exception — and an OCE the operator did not ask for — still propagates.
/// </summary>
/// <param name="console">The CLI console.</param>
/// <param name="ct">The console's Ctrl-C token.</param>
/// <param name="body">The command body.</param>
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func<Task> body)
{
try
{
await body();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
await console.Error.WriteLineAsync("Cancelled.");
}
}
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
});
}
finally
{
@@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
});
}
finally
{
@@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
await RunCancellableAsync(console, ct, async () =>
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
});
}
finally
{
@@ -117,6 +117,12 @@ public sealed class AbCipDriverOptions
/// device round trips (the whole point: a strictly sequential batch pays one full round
/// trip per tag); lowering it to <c>1</c> restores strictly sequential reads. Values below
/// <c>1</c> are clamped to <c>1</c> at the use site.
/// <para>
/// On the <see cref="AbCipTransport.Native"/> transport the effective window is at least 64: there the
/// window caps how many services one Multiple Service Packet coalesces (the device's shared scheduler packs
/// what is queued, bounded by the connection's request/reply byte budget), so a small value only multiplies
/// round trips. A value above 64 is honoured; a value below it is raised.
/// </para>
/// </summary>
public int MaxConcurrentReads { get; init; } = 8;
@@ -0,0 +1,80 @@
using System.Buffers.Binary;
using System.Collections;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Encodes a whole-array write (review 07 U10): <c>count</c> elements at the element type's wire width,
/// little-endian, exactly the layout a Logix atomic array holds and the read path decodes. Shared by both
/// runtimes so the bytes on the wire are transport-independent.
/// </summary>
/// <remarks>
/// <para><b>Element values CONVERT, they are not exact-matched.</b> ScadaBridge (and any OPC UA client that
/// round-trips what it read) writes CLR lists such as <c>int[]</c> to an INT array — the read path itself
/// publishes INT/SINT elements as <c>int</c> — so each element goes through the same <see cref="Convert"/> the
/// scalar path uses. A value that does not fit is an <see cref="OverflowException"/> (BadOutOfRange) and one that
/// cannot convert a <see cref="FormatException"/> / <see cref="InvalidCastException"/> (BadTypeMismatch), all
/// thrown before any byte is sent.</para>
/// <para>BOOL arrays (bit-packed DWORDs — a byte-per-element encode would clobber neighbour bits), STRING arrays
/// (structures) and structure arrays are not encodable here; the driver refuses them <c>BadNotSupported</c>
/// before a runtime exists.</para>
/// </remarks>
internal static class AbCipArrayCodec
{
/// <summary>Wire width of one element of an encodable array type, or <c>null</c> when the type is not encodable.</summary>
/// <param name="type">The element type.</param>
/// <returns>The element width in bytes, or <c>null</c>.</returns>
public static int? ElementWidth(AbCipDataType type) => type switch
{
AbCipDataType.SInt or AbCipDataType.USInt => 1,
AbCipDataType.Int or AbCipDataType.UInt => 2,
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
_ => null,
};
/// <summary>
/// The elements of an array write value, or <c>null</c> when the value is not a list (a scalar, a string, null).
/// Any non-string <see cref="IEnumerable"/> is accepted — <c>int[]</c>, <c>short[]</c>, <c>List&lt;object&gt;</c> …
/// </summary>
/// <param name="value">The client's write value.</param>
/// <returns>The elements, or <c>null</c>.</returns>
public static object?[]? Elements(object? value) => value switch
{
null or string => null,
IEnumerable list => [.. list.Cast<object?>()],
_ => null,
};
/// <summary>Encodes <paramref name="values"/> as <paramref name="type"/> elements.</summary>
/// <param name="type">The element type (must have an <see cref="ElementWidth"/>).</param>
/// <param name="values">The element values; their count is the element count.</param>
/// <returns>The little-endian wire bytes, <c>values.Count × ElementWidth(type)</c> long.</returns>
/// <exception cref="NotSupportedException">The type is not encodable.</exception>
public static byte[] Encode(AbCipDataType type, IReadOnlyList<object?> values)
{
var width = ElementWidth(type)
?? throw new NotSupportedException($"Array writes of {type} elements are not supported.");
var buffer = new byte[values.Count * width];
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
var span = buffer.AsSpan(i * width, width);
switch (type)
{
case AbCipDataType.SInt: span[0] = unchecked((byte)Convert.ToSByte(v)); break;
case AbCipDataType.USInt: span[0] = Convert.ToByte(v); break;
case AbCipDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(span, Convert.ToInt16(v)); break;
case AbCipDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(span, Convert.ToUInt16(v)); break;
case AbCipDataType.DInt:
case AbCipDataType.Dt: BinaryPrimitives.WriteInt32LittleEndian(span, Convert.ToInt32(v)); break;
case AbCipDataType.UDInt: BinaryPrimitives.WriteUInt32LittleEndian(span, Convert.ToUInt32(v)); break;
case AbCipDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(span, Convert.ToInt64(v)); break;
case AbCipDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(span, Convert.ToUInt64(v)); break;
case AbCipDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(span, Convert.ToSingle(v)); break;
case AbCipDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(span, Convert.ToDouble(v)); break;
}
}
return buffer;
}
}
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
/// Status-code discrimination: <c>AbStatus.Ok</c> and every device refusal (not found, out of bounds,
/// not allowed, too large/small, type mismatch, unsupported) mean the controller answered the CIP session
/// (a non-existent probe tag name still proves CIP connectivity) and are treated as "reachable - Ok = true".
/// Every other status (timeout, bad connection, bad reply, partial, abort, ...) is a transport or session
/// failure: "handshake failed - Ok = false".
/// </para>
/// </summary>
public sealed class AbCipDriverProbe : IDriverProbe
@@ -147,7 +148,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
var msg = statusCode == AbStatus.Ok
? "CIP session OK"
: "CIP session OK (controller reachable; probe tag not found)";
: RefusedProbeMessage(statusCode);
return new(true, msg, sw.Elapsed);
}
@@ -164,7 +165,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
if (IsReachableException(ex))
{
sw.Stop();
return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
return new(true, RefusedProbeMessage(ex.Status), sw.Elapsed);
}
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
@@ -180,13 +181,20 @@ public sealed class AbCipDriverProbe : IDriverProbe
}
/// <summary>
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered — <c>Ok</c>, or any
/// device refusal (<see cref="AbCipRefusals.IsDeviceRefusal(AbStatus)"/>: not found, out of bounds, not
/// allowed, too large/small, type mismatch, unsupported) — sufficient to confirm CIP reachability.
/// </summary>
/// <param name="s">The transport status to classify.</param>
/// <returns><see langword="true"/> if the status confirms CIP reachability.</returns>
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
internal static bool IsReachableStatus(AbStatus s) => s == AbStatus.Ok || AbCipRefusals.IsDeviceRefusal(s);
/// <summary>The Test-Connect message for a controller that answered but refused the probe tag.</summary>
/// <param name="status">The refusal.</param>
/// <returns>The message.</returns>
private static string RefusedProbeMessage(AbStatus status) => status == AbStatus.NotFound
? "CIP session OK (controller reachable; probe tag not found)"
: $"CIP session OK (controller reachable; probe tag refused: {status})";
/// <summary>
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
@@ -198,7 +206,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
internal static bool IsReachableException(AbTransportException ex)
{
ArgumentNullException.ThrowIfNull(ex);
return IsReachableStatus(ex.Status);
// A local refusal (the library / driver refused without any I/O) proves nothing about the controller.
return ex is not AbCipLocalRefusalException && IsReachableStatus(ex.Status);
}
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Thrown by <see cref="AbCipDriver.InitializeAsync"/> when a shutdown, dispose or newer initialization retired the
/// driver's tables while this initialization was building, so it published nothing. Deliberately NOT an
/// <see cref="OperationCanceledException"/>: nobody cancelled this call, and a caller that treats cancellation as
/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's
/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result.
/// </summary>
/// <remarks>
/// Resilience accounting (#737 m2): not being an OCE, it passes the Initialize pipeline's breaker
/// <c>ShouldHandle</c> (<c>DriverResiliencePipelineBuilder</c>) and, if its generation still matched, the actor's
/// <c>RecordFault</c>. That is practically unreachable — <c>DriverInstanceActor</c> runs initialisation single-flight
/// and drops generation-mismatched results — and a superseded attempt is at worst one counted failure. Recognising
/// it there is a runtime-side (Lane A) change if it is ever wanted.
/// </remarks>
/// <param name="message">The detail.</param>
public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
@@ -0,0 +1,15 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// A refusal raised LOCALLY — by the driver's own runtime or by libplctag (<c>ErrorNotImplemented</c>) — before or
/// without any reply from the controller. It carries <see cref="AbStatus.Unsupported"/> (so it maps to
/// <c>BadNotSupported</c>) but, unlike a device refusal, proves nothing about the device: it keeps the runtime and
/// writes no health, does NOT advance <c>LastSuccessfulRead</c>, records nothing in the connect backoff, and is not
/// "reachable" for the probe or Test Connect (review 07 / Gitea #737 I2).
/// </summary>
/// <param name="message">The refusal detail.</param>
/// <param name="inner">The underlying exception, if any.</param>
public sealed class AbCipLocalRefusalException(string message, Exception? inner = null)
: AbTransportException(AbStatus.Unsupported, message, inner);
@@ -0,0 +1,42 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// The ONE classification of a transport status into "the controller answered" vs "the wire failed", shared by
/// the data path, the probe loop, the Test-Connect probe and the native runtime's initialisation.
/// </summary>
/// <remarks>
/// A device refusal is deterministic: the session works and the controller said no to this one tag (a renamed
/// tag, <c>Arr[999]</c>, a read-protected tag, a type the authored config does not match). It is per-tag Bad,
/// keeps the tag runtime (evicting re-creates it — and, for a sole lease holder, re-Forward-Opens — on every poll),
/// never writes <c>Degraded</c>, never opens the device-wide connect backoff, and warns once per tag. Everything
/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
/// <para>
/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
/// <see cref="AbStatus.BadReply"/> (<c>AbStatusCodes.FromCip</c>, owned by <c>Driver.Ab.Transport</c>), which
/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
/// comms faults.
/// </para>
/// </remarks>
internal static class AbCipRefusals
{
/// <summary>True when <paramref name="status"/> proves the controller answered but refused this tag.</summary>
/// <param name="status">The transport status.</param>
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
public static bool IsDeviceRefusal(AbStatus status) => status is AbStatus.NotFound or AbStatus.OutOfBounds
or AbStatus.NotAllowed or AbStatus.TooLarge or AbStatus.TooSmall or AbStatus.TypeMismatch
or AbStatus.Unsupported;
/// <summary>True when <paramref name="status"/> (an <see cref="AbStatus"/> value) is a device refusal.</summary>
/// <param name="status">The runtime status word.</param>
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
public static bool IsDeviceRefusal(int status) => IsDeviceRefusal((AbStatus)status);
/// <summary>True when <paramref name="ex"/> is an <see cref="AbTransportException"/> carrying a device refusal.</summary>
/// <param name="ex">The exception.</param>
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
/// <remarks>An <see cref="AbCipLocalRefusalException"/> is never a device refusal: nothing reached the device.</remarks>
public static bool IsDeviceRefusal(Exception ex) =>
ex is AbTransportException ate and not AbCipLocalRefusalException && IsDeviceRefusal(ate.Status);
}
@@ -15,6 +15,7 @@ public static class AbCipStatusMapper
public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotReadable = 0x803A0000u;
public const uint BadNotWritable = 0x803B0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadNotSupported = 0x803D0000u;
@@ -51,4 +52,14 @@ public static class AbCipStatusMapper
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
AbOpcUaStatusMap.MapException(ex);
/// <summary>
/// Status for a READ the controller refused: as <see cref="MapStatus(AbStatus)"/>, except that a
/// <see cref="AbStatus.NotAllowed"/> answer to a read is <see cref="BadNotReadable"/> (the shared table's
/// <c>BadNotWritable</c> describes a refused write).
/// </summary>
/// <param name="status">The refusal status.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapReadStatus(AbStatus status) =>
status == AbStatus.NotAllowed ? BadNotReadable : MapStatus(status);
}
@@ -25,6 +25,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// </remarks>
public static class AbCipUdtMemberLayout
{
/// <summary>Layouts already computed, keyed by the parent definition's member list (reference identity).</summary>
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IReadOnlyList<AbCipStructureMember>, LayoutBox> Cache = new();
/// <summary>Holds a computed layout, including a "not groupable" (<c>null</c>) verdict.</summary>
private sealed class LayoutBox(IReadOnlyDictionary<string, int>? offsets)
{
public IReadOnlyDictionary<string, int>? Offsets { get; } = offsets;
}
/// <summary>
/// <see cref="TryBuild"/>, computed once per member list (review 07 P11: the read planner rebuilt the layout —
/// a dictionary allocation and an O(members) walk — for every member reference on every poll, O(members²) per
/// parent per tick). A parent definition's member list is immutable for the life of the tag table that holds
/// it, so caching by list identity is exact; a re-init builds new lists and the old entries are collected with
/// them (<see cref="System.Runtime.CompilerServices.ConditionalWeakTable{TKey,TValue}"/>).
/// </summary>
/// <param name="members">The parent definition's declared members.</param>
/// <returns>The member offsets, or <c>null</c> when the members cannot be laid out by declaration.</returns>
public static IReadOnlyDictionary<string, int>? GetOrBuild(IReadOnlyList<AbCipStructureMember> members)
{
ArgumentNullException.ThrowIfNull(members);
return Cache.GetValue(members, m => new LayoutBox(TryBuild(m))).Offsets;
}
/// <summary>
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
/// if any member type is unsupported for declaration-only layout.
@@ -67,7 +67,7 @@ public static class AbCipUdtReadPlanner
continue;
}
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
var offsets = AbCipUdtMemberLayout.GetOrBuild(parent.Members);
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
@@ -85,6 +85,17 @@ public interface IAbCipTagRuntime : IDisposable
/// <param name="bitIndex">Bit index for BOOL-within-DINT insertion, or null.</param>
/// <param name="value">Value to encode.</param>
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
/// <summary>
/// Encode a WHOLE-array write of <paramref name="values"/> (<c>values.Count</c> elements of
/// <paramref name="type"/>, at the element width — see <see cref="AbCipArrayCodec"/>) into the local buffer.
/// Callers pair this with <see cref="WriteAsync"/>. A runtime that cannot write arrays throws
/// <see cref="NotSupportedException"/>.
/// </summary>
/// <param name="type">The array element type.</param>
/// <param name="values">The element values; already length-checked against the tag's element count.</param>
void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
throw new NotSupportedException($"{GetType().Name} does not support array writes.");
}
/// <summary>
@@ -14,11 +14,21 @@ internal static class LibplctagStatusTranslation
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
// #737 I2: only ErrorNotFound is the controller saying "no such tag". ErrorNoMatch / ErrorBadDevice describe a
// session or device-level failure (no proof the controller answered this tag), so they are comms-class: with
// the widened refusal classification, folding them into NotFound made a broken session read as "reachable".
Status.ErrorNotFound => AbStatus.NotFound,
Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.BadConnection,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
// ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an
// AbCipLocalRefusalException so it never counts as "the device answered" (see FromException).
// ASSUMPTION (#737 m3): libplctag reports a failed operation by THROWING, so a NotImplemented reaches the driver
// only through FromException. AbStatus has no "local" member (it is Driver.Ab.Transport's, Lane K), so a
// NotImplemented observed only as a STATUS WORD (GetStatus after a non-throwing op) would still classify as a
// device Unsupported refusal; that combination is not produced by libplctag.NET 1.5.2.
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
@@ -70,9 +80,21 @@ internal static class LibplctagStatusTranslation
public static async Task Wrap(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// <summary>
/// The transport exception for a libplctag failure: <c>ErrorNotImplemented</c> becomes an
/// <see cref="AbCipLocalRefusalException"/> (a library refusal, no I/O); everything else an
/// <see cref="AbTransportException"/> carrying the translated status.
/// </summary>
/// <param name="ex">The libplctag exception.</param>
/// <returns>The exception to throw.</returns>
public static AbTransportException FromException(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorNotImplemented
? new AbCipLocalRefusalException($"libplctag does not implement this operation: {ex.Message}", ex)
: new AbTransportException(ToAbStatus(ex), ex.Message ?? string.Empty, ex);
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="op">The libplctag operation.</param>
@@ -80,7 +102,7 @@ internal static class LibplctagStatusTranslation
public static async Task<T> Wrap<T>(Func<Task<T>> op)
{
try { return await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
@@ -90,7 +112,7 @@ internal static class LibplctagStatusTranslation
public static T WrapSync<T>(Func<T> op)
{
try { return op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
@@ -98,6 +120,6 @@ internal static class LibplctagStatusTranslation
public static void WrapSync(Action op)
{
try { op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
}
@@ -29,8 +29,19 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = BuildTag(p);
_boolArrayElement = !p.IsArray && p.AuthoredType == AbCipDataType.Bool
&& NativeAbCipTagRuntime.TrySplitBoolArrayElement(p.TagName, out _, out _);
}
/// <summary>
/// A scalar BOOL authored over a BOOL-array element (<c>Bits[n]</c>). libplctag reads it as whatever the
/// controller returns for that element segment — a packed DWORD — and exposes no type code, so which DWORD
/// came back (and so which bit is element n) cannot be checked here. Review 07 U11: such a read used to decode
/// "low byte != 0" as Good; it is refused (<c>BadNotSupported</c>) when a DWORD came back. The native transport
/// (the default) addresses the right DWORD and decodes the bit.
/// </summary>
private readonly bool _boolArrayElement;
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The new, uninitialised libplctag tag.</returns>
@@ -120,7 +131,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
: _tag.GetInt8(offset) != 0,
: _boolArrayElement && offset == 0 && _tag.GetSize() == 4
? throw new AbTransportException(AbStatus.Unsupported,
$"'{_tag.Name}' is a BOOL-array element read as a packed DWORD; libplctag cannot say which DWORD it is — use the native transport.")
: _tag.GetInt8(offset) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
AbCipDataType.Int => (int)_tag.GetInt16(offset),
@@ -221,6 +235,23 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_ => 4,
};
/// <inheritdoc />
/// <remarks>
/// The codec's bytes are copied into the buffer the last read sized (<c>elem_count</c> elements), so the write
/// sends exactly the new array — never a stale tail. A buffer smaller than the encoded array is refused
/// (<see cref="AbStatus.TooSmall"/>) before anything is changed.
/// </remarks>
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
LibplctagStatusTranslation.WrapSync(() =>
{
var bytes = AbCipArrayCodec.Encode(type, values);
var size = _tag.GetSize();
if (size < bytes.Length)
throw new AbTransportException(AbStatus.TooSmall,
$"Array write to '{_tag.Name}': {bytes.Length} byte(s) encoded but the tag buffer holds {size}.");
for (var i = 0; i < bytes.Length; i++) _tag.SetUInt8(i, bytes[i]);
});
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
@@ -41,6 +41,34 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private int? _implicitBit;
private byte[]? _path;
// U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of DWORD n / 32 when the
// array is packed. Null for every other tag.
private int? _boolElementBit;
// Both addressings of such a tag: "Bits[n / 32]" (packed: a Logix BOOL array is stored as 0xD3 DWORDs) and the
// original "Bits[n]" (per-element: a family that answers 0xC1). _path is whichever _boolLayout selects.
private byte[]? _dwordPath;
private byte[]? _elementPath;
/// <summary>How a BOOL-array element tag's array is laid out, as the controller's REPLY TYPE last showed it.</summary>
private enum BoolLayout
{
/// <summary>No reply type seen yet (or only refusals / comms faults): request DWORD-addressed, keep asking.</summary>
Unsettled,
/// <summary>The controller answered 0xD3: address DWORD n / 32, decode bit n % 32.</summary>
Packed,
/// <summary>The controller answered 0xC1: address element n, decode its byte.</summary>
PerElement,
}
// #737 fix round 2 (N1): ONLY a reply type settles (or re-settles) the layout — 0xD3 ⇒ Packed, 0xC1 ⇒ PerElement.
// A refusal or comms fault says nothing about the layout and leaves it as it was, so a tag deployed before the
// online edit that creates its array starts reading correctly once the array exists. A reply whose type disagrees
// with the addressing it was asked under is DISCARDED (never decoded) and re-read under the right addressing.
private BoolLayout _boolLayout;
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
@@ -51,6 +79,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
private ushort _writeElements = 1;
private string? _pendingString;
private bool _disposed;
@@ -94,16 +123,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
internal int? ImplicitBit => _implicitBit;
// Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an unknown
// tag, and the driver's backoff classification depends on create throwing AbStatus.NotFound.
// Performs one read to prove the session works and learn the tag's type.
//
// A name the encoder refuses throws AbCipTagConfigurationException before any lease: it is this
// tag's configuration, not the device, and must not black out the device's other tags. A read the
// controller answers with a deterministic refusal (AbStatus.TypeMismatch / AbStatus.Unsupported)
// proves the tag exists, so it completes the initialization with that status rather than throwing:
// a throw here makes the driver dispose the runtime and re-create it - and, for a sole lease
// holder, re-Forward-Open - on every poll (mistype churn). The driver's own read reports the
// refusal.
// controller answers with a deterministic refusal (AbCipRefusals.IsDeviceRefusal: NotFound, OutOfBounds,
// NotAllowed, TooLarge/TooSmall, TypeMismatch, Unsupported) proves the session works, so it completes the
// initialization with that status rather than throwing: a throw here makes the driver dispose the runtime
// and re-create it - and, for a sole lease holder, re-Forward-Open - on every poll (a renamed tag or a
// mistype churned the lease). The driver's own read reports the refusal, per tag, on the kept runtime.
/// <inheritdoc />
public async Task InitializeAsync(CancellationToken cancellationToken)
{
@@ -116,7 +144,20 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
split = CipPath.SplitBitSelector(_p.TagName);
path = CipPath.EncodeSymbolic(split.Name);
var name = split.Name;
if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
{
_elementPath = CipPath.EncodeSymbolic(name);
_dwordPath = CipPath.EncodeSymbolic(dwordName);
_boolElementBit = bit;
_boolLayout = BoolLayout.Unsettled;
path = _dwordPath;
}
else
{
path = CipPath.EncodeSymbolic(name);
}
}
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
{
@@ -137,20 +178,45 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
{
await ReadAsync(cancellationToken).ConfigureAwait(false);
}
catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex))
{
// The controller answered — the tag exists. _status already holds the refusal.
// The controller answered. _status already holds the refusal.
}
}
/// <inheritdoc />
public async Task ReadAsync(CancellationToken cancellationToken)
public Task ReadAsync(CancellationToken cancellationToken) => ReadCoreAsync(cancellationToken, mayRelayout: true);
private async Task ReadCoreAsync(CancellationToken cancellationToken, bool mayRelayout)
{
var client = Client();
var path = Path();
try
{
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
if (_boolElementBit is not null)
{
BoolLayout? shown = type == LogixTypeCode.BoolArray ? BoolLayout.Packed
: type == LogixTypeCode.Bool ? BoolLayout.PerElement
: null; // any other type (a mistyped tag): no layout evidence; the type check below reports it
if (shown is { } layout)
{
var askedPacked = ReferenceEquals(path, _dwordPath);
SetBoolLayout(layout);
if ((layout == BoolLayout.Packed) != askedPacked)
{
// The reply is for the OTHER addressing (element n / 32 of a per-element array, or DWORD n of a
// packed one) — not this element. Discard it and ask again under the layout it just showed.
if (mayRelayout)
{
await ReadCoreAsync(cancellationToken, mayRelayout: false).ConfigureAwait(false);
return;
}
throw new AbTransportException(AbStatus.TypeMismatch,
$"'{_p.TagName}': the controller's BOOL-array layout changed between two reads; retrying next read.");
}
}
}
_data = data;
_typeCode = type;
_structHandle = handle;
@@ -164,6 +230,13 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
}
}
/// <summary>Settles a BOOL-array element tag's layout and selects the matching request path.</summary>
private void SetBoolLayout(BoolLayout layout)
{
_boolLayout = layout;
_path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath;
}
/// <summary>
/// Upper bound on one element's wire bytes for the scheduler's reply budget; 0 when the
/// authored type is unknown or a structure, which sends the read alone rather than guess.
@@ -196,6 +269,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
// An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the
// DWORD-addressed element of an element-addressed array (#737 I1). A packed layout's write is refused below
// (0xD3 DWORD — it would clobber 31 neighbours); a per-element one writes "Bits[n]" as a BOOL.
if (_boolElementBit is not null && _boolLayout == BoolLayout.Unsettled)
{
await ReadAsync(cancellationToken).ConfigureAwait(false);
path = Path();
}
if (_pendingString is { } text)
{
// A STRING writes as the type the controller reported — a Logix STRING is a structure
@@ -204,7 +285,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
await ReadAsync(cancellationToken).ConfigureAwait(false);
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
throw new AbTransportException(AbStatus.Unsupported,
throw new AbCipLocalRefusalException(
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
var payload = EncodeStringPayload(text);
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
@@ -213,7 +294,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
else if (_writeBuffer is { } buffer)
{
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
?? throw new AbTransportException(AbStatus.Unsupported,
?? throw new AbCipLocalRefusalException(
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
// would otherwise send the int bit pattern as a REAL and report Good.
@@ -223,17 +304,18 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
var width = LogixTypeCode.ByteWidth(type);
if (width > 0 && width != buffer.Length)
if (width > 0 && width * _writeElements != buffer.Length)
{
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
if (type == LogixTypeCode.BoolArray)
if (type == LogixTypeCode.BoolArray && _writeElements == 1)
throw new NotSupportedException(
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) for {_writeElements} element(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
}
await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
// U10: an array write is ONE Write Tag of _writeElements elements (the client fragments it if large).
await client.WriteAsync(path, type, null, _writeElements, buffer, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -276,7 +358,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
return type switch
{
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : DecodeScalarBool(offset),
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
@@ -315,10 +397,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
};
}
/// <inheritdoc />
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
{
ArgumentNullException.ThrowIfNull(values);
_writeBuffer = null;
_pendingString = null;
var buffer = AbCipArrayCodec.Encode(type, values); // throws (nothing staged) before any state changes
_writeElements = (ushort)Math.Clamp(values.Count, 1, ushort.MaxValue);
_writeBuffer = buffer;
}
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
_writeBuffer = null;
_writeElements = 1;
_pendingString = null;
switch (type)
{
@@ -410,6 +504,47 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
catch (Exception) { /* ForTest only; Dispose must not throw */ }
}
/// <summary>
/// A scalar BOOL. A packed (0xD3) reply is decoded ONLY as the element's own bit of the DWORD the packed layout
/// addressed; any other 0xD3 reply (no element bit, or not settled packed) is refused rather than decoded as
/// "byte 0 != 0" — that value would be some other bit, published as Good (#737 N1).
/// </summary>
private bool DecodeScalarBool(int offset)
{
if (offset == 0 && _typeCode == LogixTypeCode.BoolArray)
{
if (_boolElementBit is int element && _boolLayout == BoolLayout.Packed) return GetBit(element);
throw new AbTransportException(AbStatus.TypeMismatch,
$"'{_p.TagName}' is a scalar BOOL but the controller answered a packed BOOL-array DWORD (0xD3) with no element bit to select.");
}
return Span(offset, 1)[0] != 0;
}
/// <summary>
/// Splits a BOOL-array element reference <c>Name[n]</c> (one non-negative subscript, last segment) into the
/// DWORD it lives in (<c>Name[n / 32]</c>) and its bit within that DWORD (<c>n % 32</c>). A Logix BOOL array
/// is stored as DWORDs (type 0xD3) and its element segment addresses those DWORDs.
/// </summary>
/// <param name="name">The tag name, bit selector already split off.</param>
/// <param name="dwordName">The name addressing the containing DWORD.</param>
/// <param name="bit">The element's bit within that DWORD.</param>
/// <returns><see langword="true"/> when <paramref name="name"/> ends in a single subscript.</returns>
internal static bool TrySplitBoolArrayElement(string name, out string dwordName, out int bit)
{
dwordName = name;
bit = 0;
if (!name.EndsWith(']')) return false;
var open = name.LastIndexOf('[');
if (open <= 0) return false;
var inner = name[(open + 1)..^1];
if (inner.Length == 0 || !inner.All(char.IsAsciiDigit)
|| !int.TryParse(inner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n))
return false;
dwordName = $"{name[..open]}[{n / 32}]";
bit = n % 32;
return true;
}
private byte[] Path() =>
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
@@ -48,6 +48,16 @@ public sealed class AbLegacyDriverOptions
/// <summary>Gets or sets the default timeout for read/write operations.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
/// (driver-config key <c>coalesceReads</c>, default <see langword="true"/>). A block the device refuses falls
/// back to per-tag reads on its own; set this false to read every tag singly. <see langword="null"/> (the key
/// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI
/// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9).
/// </summary>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public bool? CoalesceReads { get; init; }
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
@@ -77,6 +77,10 @@ public static class AbLegacyTagDefinitionFactory
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
// A deterministic refusal (review 07 H16): String / Timer / Counter / Control elements are structured
// or variable-width and do not lay out as a flat array, so both runtimes refuse to decode them. Accepting
// the tag only bought a handle create + a NotSupportedException on every poll.
if (arrayLength is not null && !IsArrayElementTypeSupported(dataType)) return false;
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable.
var writable = TagConfigJson.ReadWritable(root);
@@ -173,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory
{
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
else if (accepted.ArrayLength is not null
&& ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError)
warnings.Add(widthError);
return warnings;
}
@@ -224,6 +231,78 @@ public static class AbLegacyTagDefinitionFactory
}
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType") is { } w) warnings.Add(w);
if (IsArrayFlag(root) && ReadInt(root, "arrayLength") >= 1
&& TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dt)
&& !IsArrayElementTypeSupported(dt))
warnings.Add(ArrayElementTypeError(dt));
}
/// <summary>
/// Whether <paramref name="type"/> can be the element type of an array tag. Only the flat word/long/float/bit
/// files lay out as a contiguous array; String, Timer, Counter and Control elements are structured.
/// </summary>
/// <param name="type">The authored element data type.</param>
/// <returns><see langword="true"/> for Bit, Int, AnalogInt, Long and Float.</returns>
public static bool IsArrayElementTypeSupported(AbLegacyDataType type) => type is
AbLegacyDataType.Bit or AbLegacyDataType.Int or AbLegacyDataType.AnalogInt
or AbLegacyDataType.Long or AbLegacyDataType.Float;
/// <summary>The authoring / deploy-gate message for an array of an unsupported element type.</summary>
/// <param name="type">The authored element data type.</param>
/// <returns>The message.</returns>
public static string ArrayElementTypeError(AbLegacyDataType type) =>
$"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are "
+ "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown.";
/// <summary>
/// The element width an array on <paramref name="address"/>'s data file has, as the data type that decodes /
/// encodes it: N, B, A and the I/O/S word files are 16-bit words (<see cref="AbLegacyDataType.Int"/>), L is
/// 32-bit (<see cref="AbLegacyDataType.Long"/>), F is IEEE float (<see cref="AbLegacyDataType.Float"/>).
/// <see langword="null"/> for a file with no flat array layout (ST, T, C, R, function files) or an address that
/// does not parse.
/// </summary>
/// <param name="address">The authored address.</param>
/// <returns>The file's element type, or <see langword="null"/>.</returns>
public static AbLegacyDataType? ArrayFileElementType(string address) =>
AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null;
/// <inheritdoc cref="ArrayFileElementType(string)"/>
public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch
{
"N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int,
"L" => AbLegacyDataType.Long,
"F" => AbLegacyDataType.Float,
_ => null,
};
/// <summary>
/// Why an ARRAY authored as <paramref name="type"/> on <paramref name="address"/> does not match its data
/// file's element width (review 07 r0 I3), or <see langword="null"/> when it does. Int / AnalogInt need a 16-bit
/// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit
/// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it
/// (<c>BadTypeMismatch</c>), because encoding at the authored width would corrupt the neighbouring elements.
/// </summary>
/// <param name="type">The authored element data type.</param>
/// <param name="address">The authored address.</param>
/// <returns>The mismatch message, or <see langword="null"/>.</returns>
public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
{
if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
// The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move —
// so the width rule applies to it too (review 07 r1 N5).
if (AbLegacyAddress.TryParse(address) is not { } a) return null;
var file = ArrayFileElementType(a);
var ok = type switch
{
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int,
_ => file == type,
};
return ok
? null
: $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width "
+ "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width "
+ "and writes are refused BadTypeMismatch.";
}
private static int ReadInt(JsonElement o, string name)
@@ -0,0 +1,79 @@
using System.Collections;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
/// <see cref="IAbLegacyTagRuntime.EncodeArray"/> takes. Pure: it runs BEFORE any runtime is created, so a refused
/// write never costs a wire round trip and never touches driver health.
/// </summary>
/// <remarks>
/// <para><b>Elements are converted, not exact-matched.</b> ScadaBridge writes CLR lists such as <c>int[]</c> to
/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
/// <see cref="Convert"/> the scalar path uses. A value that does not fit is <c>BadOutOfRange</c>; one that is not
/// a number at all is <c>BadTypeMismatch</c>.</para>
/// <para><b>The length must equal the tag's element count</b> (<c>BadOutOfRange</c> otherwise): a shorter write
/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.</para>
/// <para><b>Bit arrays are refused</b> (<c>BadNotSupported</c>): a PCCC bit array is bit-packed into words, so
/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
/// Control arrays never reach here — the mapper rejects them.</para>
/// </remarks>
internal static class AbLegacyArrayWriteEncoder
{
/// <summary>Converts <paramref name="value"/> to a typed array of exactly <paramref name="count"/> elements.</summary>
/// <param name="type">The tag's element data type.</param>
/// <param name="value">The value the client wrote.</param>
/// <param name="count">The tag's element count.</param>
/// <param name="typed">The converted array when the result is Good.</param>
/// <returns><see cref="AbLegacyStatusMapper.Good"/>, or the Bad status the write is answered with.</returns>
public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
{
typed = null;
if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
return AbLegacyStatusMapper.BadNotSupported;
if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
var elements = items.Cast<object?>().ToList();
if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange;
try
{
switch (type)
{
case AbLegacyDataType.Long:
{
var a = new int[count];
for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i]));
typed = a;
break;
}
case AbLegacyDataType.Float:
{
var a = new float[count];
for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i]));
typed = a;
break;
}
default:
{
var a = new short[count];
for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i]));
typed = a;
break;
}
}
return AbLegacyStatusMapper.Good;
}
catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; }
catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; }
}
// A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries.
private static object Element(object? e) => e switch
{
null => throw new InvalidCastException("null array element"),
string => throw new InvalidCastException("string array element"),
bool => throw new InvalidCastException("bool array element"),
_ => e,
};
}
@@ -0,0 +1,250 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Adjacent-element read coalescing (review 07 P7): scalar tags on the same N / B / F / L data file whose
/// elements are close together are read as ONE multi-element PCCC read (the same path array tags already use),
/// then each tag is decoded from its offset in the block. One round trip instead of one per tag.
/// </summary>
/// <remarks>
/// <para><b>Only shapes the single-tag path decodes identically are coalesced</b>: a word tag
/// (<c>Int</c>/<c>AnalogInt</c>) or a bit of a word (<c>Bit</c>, with or without <c>/n</c>) on an N or B file; a
/// <c>Float</c> on an F file; a <c>Long</c> or a bit of a long on an L file. Indirect addresses, sub-elements
/// (<c>T4:0.ACC</c>), arrays, the file-number-less system files (<c>S:</c>, <c>I:</c>, <c>O:</c>) and any tag
/// authored with a type that does not match its file's element width keep the single-tag path, so a
/// mis-authored tag still gets exactly the answer it got before.</para>
/// <para><b>The span is safe to read.</b> A block runs from its lowest to its highest member, so every element
/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags
/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file)
/// — or ANY other status the device returned, incl. libplctag's remote error — falls back to single reads for that
/// block's members in the same pass, so one bad tag never blackens its neighbours; a shape refused twice in a row
/// is remembered so it is not retried every poll. Only a NO-REPLY failure fails the members together — they
/// share the connection — exactly as their single reads would have.</para>
/// <para>Turned off with the driver-config key <c>coalesceReads: false</c>.</para>
/// </remarks>
public sealed partial class AbLegacyDriver
{
/// <summary>Largest block, in data bytes. Below the 236-byte PCCC typed-read limit, so libplctag never needs
/// to fragment a coalesced read (the native runtime would chunk it anyway).</summary>
internal const int MaxCoalescedBytes = 200;
/// <summary>Largest run of UNAUTHORED elements a block may read across to join two members.</summary>
internal const int MaxCoalesceGap = 8;
/// <summary>One unit of a device pass: a single tag, or a coalesced block of several.</summary>
private sealed record ReadUnit(ReadItem? Single, CoalescedBlock? Block);
/// <summary>A coalesced block: <paramref name="Count"/> elements of one file from <paramref name="Start"/>.</summary>
private sealed record CoalescedBlock(
string FileLetter, int FileNumber, int Start, int Count, AbLegacyDataType ElementType, List<ReadItem> Members)
{
/// <summary>The runtime-cache key; distinct from any RawPath (RawPaths never contain '|').</summary>
public string Key => $"|block|{FileLetter}{FileNumber}:{Start}+{Count}";
/// <summary>The block's base address in libplctag form.</summary>
public string StartName => $"{FileLetter}{FileNumber}:{Start}";
}
/// <summary>
/// The element type a scalar tag is decoded from when it rides a coalesced block, or <see langword="null"/>
/// when the tag keeps the single-tag path.
/// </summary>
private static AbLegacyDataType? CoalescedElementType(ReadItem item)
{
var a = item.Parsed;
if (a.IsIndirect || a.SubElement is not null || a.FileNumber is null || IsArrayTag(item.Def)) return null;
return (a.FileLetter, item.Def.DataType) switch
{
("N" or "B", AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit) => AbLegacyDataType.Int,
("F", AbLegacyDataType.Float) => AbLegacyDataType.Float,
("L", AbLegacyDataType.Long) => AbLegacyDataType.Long,
("L", AbLegacyDataType.Bit) when a.BitIndex is not null => AbLegacyDataType.Long,
_ => null,
};
}
/// <summary>
/// Plans a device pass: coalescable tags grouped per file into runs of nearby elements (a run of two or more
/// tags becomes a block), everything else a single unit. Block shapes the device refused before stay single.
/// </summary>
private List<ReadUnit> PlanReadUnits(DeviceState device, List<ReadItem> items)
{
if (_options.CoalesceReads == false || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))];
var units = new List<ReadUnit>(items.Count);
var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List<ReadItem>>();
foreach (var item in items)
{
if (CoalescedElementType(item) is not { } type)
{
units.Add(new ReadUnit(item, null));
continue;
}
var key = (item.Parsed.FileLetter, item.Parsed.FileNumber!.Value, type);
if (!groups.TryGetValue(key, out var list)) groups[key] = list = [];
list.Add(item);
}
foreach (var ((letter, file, type), members) in groups)
{
var width = type == AbLegacyDataType.Int ? 2 : 4;
var maxElements = MaxCoalescedBytes / width;
var ordered = members.OrderBy(m => m.Parsed.WordNumber).ToList();
var run = new List<ReadItem> { ordered[0] };
for (var i = 1; i <= ordered.Count; i++)
{
var next = i < ordered.Count ? ordered[i] : null;
var start = run[0].Parsed.WordNumber;
if (next is not null
&& next.Parsed.WordNumber - run[^1].Parsed.WordNumber <= MaxCoalesceGap + 1
&& next.Parsed.WordNumber - start + 1 <= maxElements)
{
run.Add(next);
continue;
}
var count = run[^1].Parsed.WordNumber - start + 1;
var block = new CoalescedBlock(letter, file, start, count, type, run);
if (run.Count < 2 || device.RefusedBlocks.ContainsKey(block.Key))
units.AddRange(run.Select(r => new ReadUnit(r, null)));
else
units.Add(new ReadUnit(null, block));
if (next is not null) run = [next];
}
}
return units;
}
/// <summary>What a block read concluded; <see cref="Fallback"/> = read the members singly instead.</summary>
private enum BlockOutcome { Done, Fallback }
/// <summary>
/// Reads one coalesced block and decodes each member from its offset. A device refusal or a local refusal
/// returns <see cref="BlockOutcome.Fallback"/> (and remembers the shape) without writing any member's result.
/// </summary>
private async Task<(BlockOutcome Outcome, OpVerdict Verdict, string? Message)> ReadBlockAsync(
DeviceState device, CoalescedBlock block, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
void FailAll(uint status)
{
foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(null, status, null, now);
}
(BlockOutcome, OpVerdict, string?) Refused(string why)
{
// The handle is never kept for a refused shape (review 07 r0 M5): it may never be planned again.
EvictRuntime(device, block.Key);
// Every refusal falls back to per-member reads THIS pass, so each member gets its own answer and a bad
// member can never black out its neighbours. The shape is only REMEMBERED after a repeat — one refusal can
// be transient (program mode, a download in progress), two in a row are deterministic.
var refusals = device.BlockRefusals.AddOrUpdate(block.Key, 1, (_, n) => n + 1);
if (refusals >= 2 && device.RefusedBlocks.TryAdd(block.Key, 0))
{
device.BlockRefusals.TryRemove(block.Key, out _);
_logger.LogInformation(
"AbLegacy coalesced read {Block} refused twice ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}",
block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress);
}
return (BlockOutcome.Fallback, OpVerdict.Neutral, null);
}
try
{
var createParams = new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: block.StartName,
Timeout: _options.Timeout,
ElementCount: block.Count);
var runtime = await CreateSingleFlightAsync(
device, device.Runtimes, block.Key, () => _tagFactory.Create(createParams), ct).ConfigureAwait(false);
int status;
Array? data = null;
var opLock = device.GetRuntimeLock(block.Key);
await opLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await runtime.ReadAsync(ct).ConfigureAwait(false);
status = runtime.GetStatus();
if (status == 0) data = runtime.DecodeArray(block.ElementType, block.Count) as Array;
}
finally
{
opLock.Release();
}
if (status != 0)
{
// ANY status the device returned (a refusal, a remote error, an unmapped reply, Unsupported) falls back
// to per-member reads; only a no-reply status fails the block's members together (review 07 r0 I1).
if (!IsNoReply((AbStatus)status))
return Refused($"device status {(AbStatus)status}");
EvictRuntime(device, block.Key);
FailAll(AbLegacyStatusMapper.MapStatus(status));
return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}");
}
if (data is null || data.Length < block.Count)
return Refused("the block decoded short");
foreach (var m in block.Members)
results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now);
device.BlockRefusals.TryRemove(block.Key, out _);
device.FirstNonZeroStatusLogged = false;
return (BlockOutcome.Done, OpVerdict.Answered, null);
}
catch (OperationCanceledException) { throw; }
catch (ConnectionThrottledException tex)
{
FailAll(AbLegacyStatusMapper.BadCommunicationError);
return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message);
}
catch (AbTransportException ex) when (!IsNoReply(ex.Status))
{
return Refused(ex.Message);
}
catch (NotSupportedException nse)
{
return Refused(nse.Message);
}
catch (Exception ex)
{
EvictRuntime(device, block.Key);
FailAll(AbLegacyStatusMapper.BadCommunicationError);
return (BlockOutcome.Done, OpVerdict.Fault, ex.Message);
}
}
/// <summary>
/// Decodes one member from the block, returning exactly the CLR type and value the single-tag path's
/// <see cref="IAbLegacyTagRuntime.DecodeValue"/> would (<c>int</c> for a word or long, <c>float</c>, <c>bool</c>).
/// </summary>
private static object DecodeMember(CoalescedBlock block, ReadItem m, Array data)
{
var offset = m.Parsed.WordNumber - block.Start;
var bit = m.Parsed.BitIndex;
switch (data)
{
case short[] words:
{
var w = words[offset];
return m.Def.DataType == AbLegacyDataType.Bit
? ((ushort)w >> (bit ?? 0) & 1) != 0
: (int)w;
}
case int[] longs:
{
var l = longs[offset];
return m.Def.DataType == AbLegacyDataType.Bit ? ((uint)l >> bit!.Value & 1) != 0 : l;
}
case float[] floats:
return floats[offset];
default:
throw new InvalidOperationException($"Unexpected block buffer {data.GetType().Name}.");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
CoalesceReads = dto.CoalesceReads,
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
@@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions
/// </summary>
public string? Transport { get; init; }
/// <summary>Gets or sets whether adjacent scalar reads are coalesced; absent means <see langword="true"/>.</summary>
public bool? CoalesceReads { get; init; }
/// <summary>
/// Gets or sets the list of devices to connect to.
/// </summary>
@@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
// The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by
// nothing before: Test Connect always ran on the caller's budget alone.
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
var (parsed, firstDevice) = ExtractTarget(opts);
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
return new(false, "Config has no host/port to probe.", null);
@@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
/// <returns>Whether the status proves the controller answered.</returns>
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
/// <summary>
/// The deadline the probe runs under: the caller's budget, tightened by the config's
/// <see cref="AbLegacyDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (same rule as the
/// Galaxy probe). A non-positive caller budget is floored to 1 s first.
/// </summary>
/// <param name="callerTimeout">The budget the host handed the probe.</param>
/// <param name="configuredSeconds">The config's <c>probeTimeoutSeconds</c>; ignored when not positive.</param>
/// <returns>The effective probe deadline.</returns>
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
{
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
if (configuredSeconds <= 0) return caller;
var configured = TimeSpan.FromSeconds(configuredSeconds);
return configured < caller ? configured : caller;
}
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
/// <param name="value">The value to encode.</param>
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
/// <summary>
/// Encodes EVERY element of a multi-element tag for the next <see cref="WriteAsync"/>, at the element width
/// of <paramref name="type"/>. <paramref name="values"/> is already element-typed by the driver
/// (<c>short[]</c> for Int/AnalogInt, <c>int[]</c> for Long, <c>float[]</c> for Float) and its length must
/// equal the runtime's element count (<see cref="AbLegacyTagCreateParams.ElementCount"/>), so no element of
/// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
/// </summary>
/// <param name="type">The PCCC element data type.</param>
/// <param name="values">The typed elements, exactly element-count long.</param>
/// <exception cref="ArgumentException">The length differs from the element count, or the array's element type
/// does not match <paramref name="type"/>.</exception>
/// <exception cref="NotSupportedException"><paramref name="type"/> has no flat array encoding.</exception>
void EncodeArray(AbLegacyDataType type, Array values);
}
public interface IAbLegacyTagFactory
@@ -23,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
// A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP
// general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the
// end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed
// out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault
// (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and
// trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error"
// value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot
// classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError.
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
/// <summary>The element count the tag was created with (1 for a scalar).</summary>
private readonly int _count;
/// <summary>Why a write to this tag is refused before the wire (<see cref="WriteRefusal"/>), else null.</summary>
private readonly string? _writeRefusal;
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = BuildTag(p);
_count = Math.Max(1, p.ElementCount);
_writeRefusal = WriteRefusal(p);
}
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
}
}
/// <inheritdoc />
public void EncodeArray(AbLegacyDataType type, Array values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Length != _count)
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
// Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
// last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
switch (type, values)
{
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
break;
case (AbLegacyDataType.Long, int[] l):
for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
break;
case (AbLegacyDataType.Float, float[] f):
for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
break;
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
default:
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
}
}
/// <summary>Disposes the tag and releases its resources.</summary>
public void Dispose() => _tag.Dispose();
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
}
}
/// <inheritdoc />
public void EncodeArray(AbLegacyDataType type, Array values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Length != _count)
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
// Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
// write sends comes from the last read.
switch (type, values)
{
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
break;
case (AbLegacyDataType.Long, int[] l):
for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
break;
case (AbLegacyDataType.Float, float[] f):
for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
break;
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
default:
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
}
}
/// <summary>
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
@@ -38,13 +38,16 @@ public static class CalculationDriverFactoryExtensions
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
/// <param name="scripting">The node-wide <c>Scripting</c> options every Calculation driver inherits; a key
/// authored in a driver's own config overrides that one knob. Null ⇒ the compiled defaults.</param>
public static void Register(
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory = null,
ScriptRootLogger? scriptRoot = null)
ScriptRootLogger? scriptRoot = null,
ScriptingOptions? scripting = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot, scripting));
}
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
@@ -63,6 +66,18 @@ public static class CalculationDriverFactoryExtensions
public static CalculationDriver CreateInstance(
string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory, scriptRoot, nodeScripting: null);
/// <summary>The full overload: also takes the node-wide <c>Scripting</c> options the driver inherits.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
/// <param name="nodeScripting">The node-wide run-guard options; null ⇒ the compiled defaults.</param>
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
public static CalculationDriver CreateInstance(
string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot, ScriptingOptions? nodeScripting)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -71,15 +86,14 @@ public static class CalculationDriverFactoryExtensions
?? throw new InvalidOperationException(
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
// Run-guard knobs are optional and default to the node-wide ScriptingOptions defaults; only an
// explicitly authored key overrides one (so a config carrying neither binds Scripting = null).
ScriptingOptions? scripting = null;
if (dto.MaxAllocBytesPerRun is not null || dto.QuarantineAfterConsecutiveTimeouts is not null)
{
scripting = new ScriptingOptions();
if (dto.MaxAllocBytesPerRun is { } alloc) scripting.MaxAllocBytesPerRun = alloc;
if (dto.QuarantineAfterConsecutiveTimeouts is { } n) scripting.QuarantineAfterConsecutiveTimeouts = n;
}
var scripting = ResolveScripting(
dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting, out var adjustments);
var logger = loggerFactory?.CreateLogger<CalculationDriver>();
// An authored value is never changed silently: the run guard would clamp it anyway, without a word.
foreach (var adjustment in adjustments)
logger?.LogWarning(
"Calculation driver {DriverInstanceId}: its run-guard config was out of range and is clamped: {Adjustment}",
driverInstanceId, adjustment);
var options = new CalculationDriverOptions
{
@@ -89,9 +103,37 @@ public static class CalculationDriverFactoryExtensions
Scripting = scripting,
};
return new CalculationDriver(
options, driverInstanceId, scriptRoot,
logger: loggerFactory?.CreateLogger<CalculationDriver>());
return new CalculationDriver(options, driverInstanceId, scriptRoot, logger: logger);
}
/// <summary>
/// The run-guard options for one Calculation driver. The node-wide options are the base. A key authored
/// in the driver's config overrides only its own knob, on a copy, so the shared node object is never
/// written. With neither, the result is null and the guard uses its compiled defaults.
/// </summary>
/// <param name="maxAllocBytesPerRun">The driver config's <c>maxAllocBytesPerRun</c>, or null.</param>
/// <param name="quarantineAfterConsecutiveTimeouts">The driver config's <c>quarantineAfterConsecutiveTimeouts</c>, or null.</param>
/// <param name="nodeScripting">The node-wide options, or null.</param>
/// <param name="adjustments">The clamps applied to the driver's OWN keys (the node options were already
/// clamped and logged at bind), for the caller to log. Empty when nothing was out of range.</param>
/// <returns>The options to hand the driver, or null.</returns>
internal static ScriptingOptions? ResolveScripting(
long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting,
out IReadOnlyList<string> adjustments)
{
adjustments = [];
if (maxAllocBytesPerRun is null && quarantineAfterConsecutiveTimeouts is null) return nodeScripting;
var resolved = new ScriptingOptions();
if (nodeScripting is not null)
{
resolved.MaxAllocBytesPerRun = nodeScripting.MaxAllocBytesPerRun;
resolved.QuarantineAfterConsecutiveTimeouts = nodeScripting.QuarantineAfterConsecutiveTimeouts;
resolved.StuckOrphanQuarantineFactor = nodeScripting.StuckOrphanQuarantineFactor;
}
if (maxAllocBytesPerRun is { } alloc) resolved.MaxAllocBytesPerRun = alloc;
if (quarantineAfterConsecutiveTimeouts is { } n) resolved.QuarantineAfterConsecutiveTimeouts = n;
return resolved.Sanitized(out adjustments);
}
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid S7 address object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def)
=> FromTagConfig(tagConfig, rawPath, out def, out _);
/// <summary>
/// <see cref="FromTagConfig(string, string, out S7TagDefinition)"/>, also reporting WHY a structurally
/// valid blob was refused by <see cref="S7TagShapeRules"/> (an unparseable address, or a shape the
/// driver cannot serve). <paramref name="shapeRejection"/> stays <see langword="null"/> for a JSON-level
/// refusal (missing keys, bad enum), which <see cref="Inspect"/> explains on its own.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <param name="shapeRejection">The shape/address rejection reason, when that is why it refused.</param>
/// <returns><see langword="true"/> when the driver can serve the tag.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection)
{
def = null!;
shapeRejection = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable.
var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
var mapped = new S7TagDefinition(
Name: rawPath,
Address: address,
DataType: dataType,
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount);
// The address must parse and the shape must be one the driver can serve. These used to be
// init-time guards that THREW and faulted every tag on the PLC; refusing here skips only
// this tag, and the editor + deploy gate (which ask this method) refuse it too.
if (S7TagShapeRules.Validate(mapped, out _) is { } reason)
{
shapeRejection = reason;
return false;
}
def = mapped;
return true;
}
catch (JsonException) { return false; }
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>address</c>, an <b>absent</b> <c>dataType</c> (see <see cref="DeclaresDataType"/>), a
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT, so a
/// typo rejects the tag), or an out-of-range <c>stringLength</c> on a <c>String</c> tag. Such a tag
/// typo rejects the tag), an out-of-range <c>stringLength</c> on a <c>String</c> tag, or a shape
/// <see cref="S7TagShapeRules"/> refuses (an unparseable address, a wrong access width, a mis-typed
/// Timer/Counter, an unsupported array). Such a tag
/// is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
/// </summary>
@@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory
// 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 _, out var shapeRejection)) return warnings;
if (shapeRejection is not null)
{
warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown.");
return warnings;
}
try
{
@@ -0,0 +1,149 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// <b>The single authority on which S7 tag shapes the driver can serve.</b> Consulted by
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/>, so the AdminUI editor, the deploy gate
/// (<see cref="S7TagDefinitionFactory.Inspect"/>) and the driver's own init all refuse the same
/// tags with the same words.
/// </summary>
/// <remarks>
/// <para>
/// These rules used to be init-time guards in <c>S7Driver</c> that THREW. One mis-authored tag
/// (<c>DB1.DBZ0</c>, <c>DB1.DBW0</c> typed <c>Float64</c>, <c>T5</c> typed <c>Int32</c>) faulted the
/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on
/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob
/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads
/// <c>BadNodeIdUnknown</c>) and the deploy gate warns about it.
/// </para>
/// <para>
/// Every rule rejects a tag that could never be served correctly. None of them can newly break
/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded)
/// every read of that tag.
/// </para>
/// </remarks>
public static class S7TagShapeRules
{
/// <summary>
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an access-width
/// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a
/// non-byte suffix (DBW/DBD/...) would mis-frame its value.
/// </summary>
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes =
[
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
S7DataType.WString,
S7DataType.Char,
S7DataType.WChar,
S7DataType.Dtl,
S7DataType.Date,
S7DataType.Tod,
S7DataType.Time,
S7DataType.S5Time,
];
/// <summary>True when <paramref name="dataType"/> is a wide/structured type read from a byte-anchored block.</summary>
/// <param name="dataType">The declared data type.</param>
/// <returns><see langword="true"/> for the byte-anchored types.</returns>
public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType);
/// <summary>
/// Judges one tag definition. Returns <see langword="null"/> when the driver can serve it, or
/// the operator-facing reason it cannot.
/// </summary>
/// <param name="def">The mapped tag definition.</param>
/// <param name="parsed">The parsed address when this returns <see langword="null"/>.</param>
/// <returns>The rejection reason, or <see langword="null"/> when the tag is servable.</returns>
public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed)
{
ArgumentNullException.ThrowIfNull(def);
parsed = default;
S7ParsedAddress addr;
try
{
addr = S7AddressParser.Parse(def.Address);
}
catch (FormatException ex)
{
return $"S7 address '{def.Address}' does not parse: {ex.Message}";
}
var isWide = IsByteAnchored(def.DataType);
var isArray = def.ArrayCount is >= 1;
// Wide-type arrays have no decoder.
if (isWide && isArray)
return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " +
"Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array.";
// Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count.
if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64)
return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " +
"DataType=Float64 (decoded to seconds, read-only).";
if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32)
return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " +
"DataType=Int32 (decoded to a count, read-only).";
if (addr.Area is S7Area.Timer or S7Area.Counter)
{
if (isArray)
return $"S7 Timer/Counter address '{def.Address}' cannot be an array.";
if (def.Writable)
return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " +
"are read-only; set Writable=false.";
parsed = addr;
return null;
}
// Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB).
if (isWide)
{
if (addr.Size != S7Size.Byte)
return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " +
"must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'.";
parsed = addr;
return null;
}
// Narrow types: the declared type must match the address's access width, or every read fails
// (or decodes garbage) with a type mismatch.
var required = def.DataType switch
{
S7DataType.Bool => S7Size.Bit,
S7DataType.Byte => S7Size.Byte,
S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word,
_ => S7Size.DWord, // Int32 / UInt32 / Float32
};
if (addr.Size != required)
return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " +
$"A {def.DataType} tag needs a {SuffixHint(required)} address.";
if (isArray && def.DataType == S7DataType.Bool)
{
// Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A
// non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n).
if (addr.BitOffset != 0)
return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " +
"one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0').";
// A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches.
if (def.Writable)
return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " +
"supported (a byte-granular write would clobber neighbouring bits); set Writable=false " +
"or write the bits as individual scalar Bool tags.";
}
parsed = addr;
return null;
}
private static string SuffixHint(S7Size size) => size switch
{
S7Size.Bit => "bit (DBX/M/I/Q n.b)",
S7Size.Byte => "byte (DBB/MB/IB/QB)",
S7Size.Word => "word (DBW/MW/IW/QW)",
_ => "double-word (DBD/MD/ID/QD)",
};
}
File diff suppressed because it is too large Load Diff
@@ -42,6 +42,14 @@ public sealed class S7DriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
// The S7 handshake + test read are bounded by the driver's own per-operation timeout
// (config "timeoutMs", what the deployed driver will wait), capped by the caller's overall
// Test Connect timeout. The options type carries it as a TimeSpan the JSON never names, so it
// used to be ignored and every handshake waited the full Test Connect timeout (R07:H7).
var opTimeout = ReadTimeoutMs(configJson) is { } ms && TimeSpan.FromMilliseconds(ms) < timeout
? TimeSpan.FromMilliseconds(ms)
: timeout;
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
var sw = Stopwatch.StartNew();
try
@@ -71,9 +79,9 @@ public sealed class S7DriverProbe : IDriverProbe
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;
plc.ReadTimeout = (int)opTimeout.TotalMilliseconds;
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(timeout);
handshakeCts.CancelAfter(opTimeout);
try
{
await plc.OpenAsync(handshakeCts.Token);
@@ -128,15 +136,17 @@ public sealed class S7DriverProbe : IDriverProbe
return null;
}
catch (OperationCanceledException) { throw; }
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
// S7.Net's read paths raise the CPU's item error as a BARE Exception ("Received error from
// PLC: …"), not a PlcException, so both classifiers walk the whole chain (the driver's own).
catch (Exception ex) when (S7Driver.IsAccessDenied(ex))
{
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);
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}", null);
}
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
catch (Exception ex) when (S7Driver.IsRefusal(ex))
{
return null; // address not available on this CPU — not a connection problem
return null; // address not available on this CPU — the CPU answered; not a connection problem
}
catch (Exception ex)
{
@@ -146,4 +156,31 @@ public sealed class S7DriverProbe : IDriverProbe
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port);
/// <summary>
/// The config's <c>timeoutMs</c> (any casing, as the driver factory binds it), or null when absent
/// or not a positive integer.
/// </summary>
/// <param name="configJson">The driver config.</param>
/// <returns>The configured per-operation timeout in milliseconds, or null.</returns>
internal static int? ReadTimeoutMs(string configJson)
{
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "timeoutMs", StringComparison.OrdinalIgnoreCase)) continue;
return prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var ms) && ms > 0
? ms
: null;
}
return null;
}
catch (JsonException)
{
return null;
}
}
}
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <b>The safety invariant is that a span never extends past its last member's end.</b> A
/// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the
/// gap budget buys), but every byte it requests lies inside a range some member was going
/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where
/// the individual per-tag reads would have succeeded.
/// to read anyway.
/// </para>
/// <para>
/// <b>That does NOT make a grouped read as safe as the per-tag reads</b> (R07:P2): the block is
/// the union of its members' ranges, so ONE member the CPU refuses (running past the DB's real
/// end, an over-declared STRING) makes it refuse the whole block, neighbours included.
/// <see cref="S7Driver.ReadAsync"/> owns that case: a refused multi-member span is re-read one
/// member at a time, and the members that must not be grouped are left out of later plans.
/// </para>
/// <para>
/// <b>Timer and Counter tags are never grouped.</b> Their