Merge branch 'master' into remed/lane-G2
This commit is contained in:
@@ -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<object></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.
|
||||
|
||||
+79
@@ -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.
|
||||
|
||||
+56
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user