dfc62cef14
K-F1 (AbCip #737 F1): FromCip maps general 0x1C and 0x20 to Unsupported and 0xFF/0x2104 to OutOfBounds, so a controller refusal is per-tag Bad instead of a BadReply comms fault. A Forward Open refused with one of them is still BadConnection. Every CIP code is now listed with its meaning; 0x01/0x07 stay opaque on purpose. K-F3 (AbLegacy F8, review 07 r0 I4): FromPccc follows the DF1 STS table: 0x20/0x30/0x90 -> BadConnection (a bridge reporting an unreachable END node), 0x50 and EXT 0x06 -> NotFound, EXT 0x07 -> OutOfBounds, 0x60 -> NotAllowed, 0x70/0xB0 -> DeviceStateConflict. Golden ParseReply tests per STS, and an end-to-end AbLegacy test: STS 0x30 degrades with LastSuccessfulRead preserved; the 0x60 positive control stays Healthy and advances it. Both were red before. Driver edits are doc remarks and the two runtime tests the old table pinned. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
146 lines
8.2 KiB
C#
146 lines
8.2 KiB
C#
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
|
|
|
/// <summary>Parameters for a (Large) Forward Open. <see cref="ConnectionSize"/> is the maximum CIP
|
|
/// connection payload in bytes (e.g. 4002 for Large, 504 for regular).</summary>
|
|
public sealed record ForwardOpenParams(
|
|
bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
|
|
byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
|
|
|
|
public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
|
|
|
|
/// <summary>A target refused a Forward Open. The refusal is data, not just message text: callers (and
|
|
/// diagnostics) read <see cref="GeneralStatus"/> / <see cref="ExtendedStatus"/> directly.</summary>
|
|
public sealed class ForwardOpenRefusedException : AbTransportException
|
|
{
|
|
/// <summary>Creates the exception for a refused Forward Open.</summary>
|
|
/// <param name="status">The mapped transport status.</param>
|
|
/// <param name="generalStatus">The CIP general status of the refusal.</param>
|
|
/// <param name="extendedStatus">The CIP extended-status words of the refusal.</param>
|
|
public ForwardOpenRefusedException(AbStatus status, byte generalStatus, IReadOnlyList<ushort> extendedStatus)
|
|
: base(status, $"Forward Open refused: general 0x{generalStatus:X2}, extended {string.Join(",", extendedStatus.Select(e => $"0x{e:X4}"))}.")
|
|
{
|
|
GeneralStatus = generalStatus;
|
|
ExtendedStatus = [.. extendedStatus]; // defensive copy: the exception is immutable
|
|
}
|
|
|
|
/// <summary>The CIP general status of the refusal.</summary>
|
|
public byte GeneralStatus { get; }
|
|
|
|
/// <summary>The CIP extended-status words of the refusal (may be empty).</summary>
|
|
public IReadOnlyList<ushort> ExtendedStatus { get; }
|
|
}
|
|
|
|
/// <summary>Connection Manager (class 6) request builders and reply parsers.</summary>
|
|
public static class ConnectionManagerServices
|
|
{
|
|
/// <summary>Priority/time-tick byte.</summary>
|
|
public const byte PriorityTick = 0x0A;
|
|
|
|
/// <summary>Timeout ticks. The brief proposed 0x0E; every libplctag capture (Forward Open, Forward
|
|
/// Close, Unconnected Send) carries 0x05, and the capture wins.</summary>
|
|
public const byte TimeoutTicks = 0x05;
|
|
|
|
/// <summary>Wraps an embedded request in an Unconnected Send to the Connection Manager.</summary>
|
|
/// <param name="embedded">The embedded CIP request.</param>
|
|
/// <param name="route">The encoded route path to the target.</param>
|
|
/// <returns>The encoded Unconnected Send request.</returns>
|
|
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
|
|
{
|
|
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
|
|
.U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
|
|
if ((embedded.Length & 1) == 1) w.U8(0);
|
|
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
|
|
}
|
|
|
|
/// <summary>Builds a (Large) Forward Open request.</summary>
|
|
/// <param name="p">The Forward Open parameters.</param>
|
|
/// <returns>The encoded Forward Open request.</returns>
|
|
public static byte[] ForwardOpen(ForwardOpenParams p)
|
|
{
|
|
if (p.ConnectionSize < 1 || p.ConnectionSize > (p.Large ? 0xFFFF : 0x1FF))
|
|
throw new ArgumentOutOfRangeException(nameof(p), p.ConnectionSize,
|
|
p.Large ? "Large connection size must be 1..65535." : "Regular connection size must be 1..511.");
|
|
var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
|
|
var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
|
|
.U8(PriorityTick).U8(TimeoutTicks)
|
|
.U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
|
|
.U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
|
|
WriteSide(w, p);
|
|
WriteSide(w, p);
|
|
return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
|
|
}
|
|
|
|
private static void WriteSide(CipWriter w, ForwardOpenParams p)
|
|
{
|
|
w.U32(p.RpiMicros);
|
|
if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
|
|
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
|
|
}
|
|
|
|
/// <summary>Parses a Forward Open reply.</summary>
|
|
/// <param name="reply">The CIP reply to parse.</param>
|
|
/// <returns>The granted connection ids and API values.</returns>
|
|
/// <exception cref="ForwardOpenRefusedException">The target refused the Forward Open.</exception>
|
|
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
|
|
{
|
|
if (reply.GeneralStatus != 0)
|
|
{
|
|
// An unclassified refusal, or one of the request-level codes AbStatusCodes maps to a per-request refusal
|
|
// for TAG services (0x1C / 0x20 / 0xFF-0x2104), is a connection failure here: no tag can be served.
|
|
var status = reply.Status == AbStatus.BadReply
|
|
|| AbStatusCodes.IsRequestLevelRefusal(reply.GeneralStatus, reply.ExtendedStatus)
|
|
? AbStatus.BadConnection
|
|
: reply.Status;
|
|
throw new ForwardOpenRefusedException(status, reply.GeneralStatus, reply.ExtendedStatus);
|
|
}
|
|
var r = new CipReader(reply.Data.Span);
|
|
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
|
|
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
|
|
}
|
|
|
|
/// <summary>True when a refused Large Forward Open should be retried as a regular one: general 0x08
|
|
/// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).</summary>
|
|
/// <param name="r">The refused Forward Open reply.</param>
|
|
/// <returns><see langword="true"/> when a regular Forward Open should be tried.</returns>
|
|
public static bool ShouldFallBackToRegularOpen(CipReply r) =>
|
|
r.GeneralStatus == 0x08
|
|
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
|
|
|
|
/// <summary>Connection Manager extended status: target connection not found.</summary>
|
|
public const ushort TargetConnectionNotFound = 0x0107;
|
|
|
|
/// <summary>Connection Manager extended status: the connection timed out.</summary>
|
|
public const ushort ConnectionTimedOut = 0x0203;
|
|
|
|
/// <summary>
|
|
/// For a reply received over a class-3 connection: the extended status when the target says the connection
|
|
/// itself no longer exists — general 0x01 (Connection Failure) with extended 0x0107 (target connection not
|
|
/// found) or 0x0203 (connection timed out) — else null. Any other status, a tag-level one included, describes
|
|
/// the request and leaves the connection alone.
|
|
/// </summary>
|
|
/// <param name="reply">The raw CIP reply bytes.</param>
|
|
/// <returns>The extended status when the connection is gone; otherwise <see langword="null"/>.</returns>
|
|
public static ushort? IsConnectionGone(ReadOnlySpan<byte> reply)
|
|
{
|
|
// Reply header: service | reserved | general status | extended-status word count | words...
|
|
if (reply.Length < 6 || reply[2] != 0x01 || reply[3] == 0) return null;
|
|
var extended = (ushort)(reply[4] | (reply[5] << 8));
|
|
return extended is TargetConnectionNotFound or ConnectionTimedOut ? extended : null;
|
|
}
|
|
|
|
/// <summary>Builds a Forward Close request.</summary>
|
|
/// <param name="connectionSerial">The connection serial number used at open.</param>
|
|
/// <param name="vendorId">The originator vendor id used at open.</param>
|
|
/// <param name="originatorSerial">The originator serial number used at open.</param>
|
|
/// <param name="route">The encoded route path to the target.</param>
|
|
/// <returns>The encoded Forward Close request.</returns>
|
|
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
|
|
{
|
|
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
|
|
return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
|
|
.U16(connectionSerial).U16(vendorId).U32(originatorSerial)
|
|
.U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
|
|
}
|
|
}
|